From a35c9fa159173388d88ef77e1d31f53488aad094 Mon Sep 17 00:00:00 2001 From: Andrew Branson Date: Fri, 3 Apr 2026 22:55:30 +0200 Subject: Generalize for all fediverse accounts --- README.md | 98 +-- buteo-plugins/buteo-common/buteo-common.pri | 2 +- buteo-plugins/buteo-common/buteo-common.pro | 2 +- buteo-plugins/buteo-plugins.pro | 8 +- .../buteo-sync-plugin-fediverse-notifications.pro | 66 ++ .../fediverse-notifications.xml | 6 + .../fediverse.Notifications.xml | 17 + .../fediversedatatypesyncadaptor.cpp | 240 +++++ .../fediversedatatypesyncadaptor.h | 70 ++ .../fediversenotificationsplugin.cpp | 96 ++ .../fediversenotificationsplugin.h | 54 ++ .../fediversenotificationssyncadaptor.cpp | 970 +++++++++++++++++++++ .../fediversenotificationssyncadaptor.h | 104 +++ .../buteo-sync-plugin-fediverse-posts.pro | 41 + .../fediverse-posts.xml | 6 + .../fediverse.Posts.xml | 17 + .../fediversedatatypesyncadaptor.cpp | 240 +++++ .../fediversedatatypesyncadaptor.h | 71 ++ .../fediversepostsplugin.cpp | 49 ++ .../fediversepostsplugin.h | 54 ++ .../fediversepostssyncadaptor.cpp | 260 ++++++ .../fediversepostssyncadaptor.h | 61 ++ .../buteo-sync-plugin-mastodon-notifications.pro | 66 -- .../mastodon-notifications.xml | 6 - .../mastodon.Notifications.xml | 17 - .../mastodondatatypesyncadaptor.cpp | 240 ----- .../mastodondatatypesyncadaptor.h | 70 -- .../mastodonnotificationsplugin.cpp | 96 -- .../mastodonnotificationsplugin.h | 54 -- .../mastodonnotificationssyncadaptor.cpp | 970 --------------------- .../mastodonnotificationssyncadaptor.h | 104 --- .../buteo-sync-plugin-mastodon-posts.pro | 41 - .../mastodon-posts.xml | 6 - .../mastodon.Posts.xml | 17 - .../mastodondatatypesyncadaptor.cpp | 232 ----- .../mastodondatatypesyncadaptor.h | 69 -- .../mastodonpostsplugin.cpp | 49 -- .../mastodonpostsplugin.h | 54 -- .../mastodonpostssyncadaptor.cpp | 257 ------ .../mastodonpostssyncadaptor.h | 61 -- common/common.pri | 2 +- common/common.pro | 10 +- common/fediverseauthutils.h | 153 ++++ common/fediversepostsdatabase.cpp | 147 ++++ common/fediversepostsdatabase.h | 55 ++ common/fediversetextutils.h | 122 +++ common/mastodonauthutils.h | 143 --- common/mastodonpostsdatabase.cpp | 142 --- common/mastodonpostsdatabase.h | 53 -- common/mastodontextutils.h | 122 --- .../FediverseFeedItem.qml | 362 ++++++++ .../eventsview-plugin-fediverse.pro | 65 ++ .../fediverse-delegate.qml | 185 ++++ .../fediversepostactions.cpp | 317 +++++++ .../fediversepostactions.h | 82 ++ .../fediversepostsmodel.cpp | 200 +++++ .../fediversepostsmodel.h | 80 ++ .../eventsview-plugin-fediverse/plugin.cpp | 27 + .../eventsview-plugin-fediverse/qmldir | 4 + .../MastodonFeedItem.qml | 488 ----------- .../eventsview-plugin-mastodon.pro | 64 -- .../mastodon-delegate.qml | 214 ----- .../mastodonpostactions.cpp | 317 ------- .../mastodonpostactions.h | 82 -- .../mastodonpostsmodel.cpp | 165 ---- .../mastodonpostsmodel.h | 79 -- .../eventsview-plugin-mastodon/plugin.cpp | 27 - .../eventsview-plugin-mastodon/qmldir | 4 - eventsview-plugins/eventsview-plugins.pro | 2 +- icons/svgs/icons/icon-l-fediverse.svg | 10 + icons/svgs/icons/icon-l-mastodon.svg | 10 - rpm/sailfish-account-fediverse.spec | 133 +++ rpm/sailfish-account-mastodon.spec | 133 --- sailfish-account-fediverse.pro | 18 + sailfish-account-mastodon.pro | 18 - .../accounts-translations-plugin.pro | 6 +- settings/accounts-translations-plugin/plugin.cpp | 182 +++- settings/accounts-translations-plugin/qmldir | 4 +- settings/accounts/accounts.pro | 38 +- settings/accounts/providers/fediverse.provider | 35 + settings/accounts/providers/mastodon.provider | 35 - .../accounts/services/fediverse-microblog.service | 32 + .../services/fediverse-notifications.service | 32 + .../accounts/services/fediverse-sharing.service | 31 + .../accounts/services/mastodon-microblog.service | 32 - .../services/mastodon-notifications.service | 32 - .../accounts/services/mastodon-sharing.service | 31 - settings/accounts/ui/FediverseSettingsDisplay.qml | 181 ++++ settings/accounts/ui/MastodonSettingsDisplay.qml | 174 ---- settings/accounts/ui/fediverse-settings.qml | 115 +++ settings/accounts/ui/fediverse-update.qml | 229 +++++ settings/accounts/ui/fediverse.qml | 679 +++++++++++++++ settings/accounts/ui/mastodon-settings.qml | 107 --- settings/accounts/ui/mastodon-update.qml | 228 ----- settings/accounts/ui/mastodon.qml | 492 ----------- .../fediverseshareplugin/FediverseSharePost.qml | 138 +++ .../fediverseshareplugin/fediverseplugininfo.cpp | 62 ++ .../fediverseshareplugin/fediverseplugininfo.h | 35 + .../fediverseshareplugin/fediverseshareplugin.cpp | 29 + .../fediverseshareplugin/fediverseshareplugin.h | 28 + .../fediverseshareplugin/fediverseshareplugin.pro | 32 + .../fediverseshareservicestatus.cpp | 305 +++++++ .../fediverseshareservicestatus.h | 82 ++ .../fediversetransferplugin/fediverseapi.cpp | 255 ++++++ .../fediversetransferplugin/fediverseapi.h | 65 ++ .../fediversetransferplugin.cpp | 31 + .../fediversetransferplugin.h | 33 + .../fediversetransferplugin.pro | 30 + .../fediversetransferplugin/fediverseuploader.cpp | 252 ++++++ .../fediversetransferplugin/fediverseuploader.h | 59 ++ .../mastodonshareplugin/MastodonSharePost.qml | 138 --- .../mastodonshareplugin/mastodonplugininfo.cpp | 60 -- .../mastodonshareplugin/mastodonplugininfo.h | 35 - .../mastodonshareplugin/mastodonshareplugin.cpp | 29 - .../mastodonshareplugin/mastodonshareplugin.h | 28 - .../mastodonshareplugin/mastodonshareplugin.pro | 30 - .../mastodonshareservicestatus.cpp | 297 ------- .../mastodonshareservicestatus.h | 81 -- .../mastodontransferplugin/mastodonapi.cpp | 255 ------ .../mastodontransferplugin/mastodonapi.h | 65 -- .../mastodontransferplugin.cpp | 31 - .../mastodontransferplugin.h | 33 - .../mastodontransferplugin.pro | 29 - .../mastodontransferplugin/mastodonuploader.cpp | 252 ------ .../mastodontransferplugin/mastodonuploader.h | 59 -- transferengine-plugins/transferengine-plugins.pro | 2 +- 126 files changed, 7406 insertions(+), 7125 deletions(-) create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-notifications/buteo-sync-plugin-fediverse-notifications.pro create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediverse-notifications.xml create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediverse.Notifications.xml create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversedatatypesyncadaptor.cpp create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversedatatypesyncadaptor.h create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationsplugin.cpp create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationsplugin.h create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationssyncadaptor.cpp create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-notifications/fediversenotificationssyncadaptor.h create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-posts/buteo-sync-plugin-fediverse-posts.pro create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-posts/fediverse-posts.xml create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-posts/fediverse.Posts.xml create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversedatatypesyncadaptor.cpp create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversedatatypesyncadaptor.h create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostsplugin.cpp create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostsplugin.h create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostssyncadaptor.cpp create mode 100644 buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostssyncadaptor.h delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-notifications/buteo-sync-plugin-mastodon-notifications.pro delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodon-notifications.xml delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodon.Notifications.xml delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodondatatypesyncadaptor.cpp delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodondatatypesyncadaptor.h delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodonnotificationsplugin.cpp delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodonnotificationsplugin.h delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodonnotificationssyncadaptor.cpp delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodonnotificationssyncadaptor.h delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/buteo-sync-plugin-mastodon-posts.pro delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon-posts.xml delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon.Posts.xml delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.cpp delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.h delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.cpp delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.h delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp delete mode 100644 buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.h create mode 100644 common/fediverseauthutils.h create mode 100644 common/fediversepostsdatabase.cpp create mode 100644 common/fediversepostsdatabase.h create mode 100644 common/fediversetextutils.h delete mode 100644 common/mastodonauthutils.h delete mode 100644 common/mastodonpostsdatabase.cpp delete mode 100644 common/mastodonpostsdatabase.h delete mode 100644 common/mastodontextutils.h create mode 100644 eventsview-plugins/eventsview-plugin-fediverse/FediverseFeedItem.qml create mode 100644 eventsview-plugins/eventsview-plugin-fediverse/eventsview-plugin-fediverse.pro create mode 100644 eventsview-plugins/eventsview-plugin-fediverse/fediverse-delegate.qml create mode 100644 eventsview-plugins/eventsview-plugin-fediverse/fediversepostactions.cpp create mode 100644 eventsview-plugins/eventsview-plugin-fediverse/fediversepostactions.h create mode 100644 eventsview-plugins/eventsview-plugin-fediverse/fediversepostsmodel.cpp create mode 100644 eventsview-plugins/eventsview-plugin-fediverse/fediversepostsmodel.h create mode 100644 eventsview-plugins/eventsview-plugin-fediverse/plugin.cpp create mode 100644 eventsview-plugins/eventsview-plugin-fediverse/qmldir delete mode 100644 eventsview-plugins/eventsview-plugin-mastodon/MastodonFeedItem.qml delete mode 100644 eventsview-plugins/eventsview-plugin-mastodon/eventsview-plugin-mastodon.pro delete mode 100644 eventsview-plugins/eventsview-plugin-mastodon/mastodon-delegate.qml delete mode 100644 eventsview-plugins/eventsview-plugin-mastodon/mastodonpostactions.cpp delete mode 100644 eventsview-plugins/eventsview-plugin-mastodon/mastodonpostactions.h delete mode 100644 eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.cpp delete mode 100644 eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.h delete mode 100644 eventsview-plugins/eventsview-plugin-mastodon/plugin.cpp delete mode 100644 eventsview-plugins/eventsview-plugin-mastodon/qmldir create mode 100644 icons/svgs/icons/icon-l-fediverse.svg delete mode 100644 icons/svgs/icons/icon-l-mastodon.svg create mode 100644 rpm/sailfish-account-fediverse.spec delete mode 100644 rpm/sailfish-account-mastodon.spec create mode 100644 sailfish-account-fediverse.pro delete mode 100644 sailfish-account-mastodon.pro create mode 100644 settings/accounts/providers/fediverse.provider delete mode 100644 settings/accounts/providers/mastodon.provider create mode 100644 settings/accounts/services/fediverse-microblog.service create mode 100644 settings/accounts/services/fediverse-notifications.service create mode 100644 settings/accounts/services/fediverse-sharing.service delete mode 100644 settings/accounts/services/mastodon-microblog.service delete mode 100644 settings/accounts/services/mastodon-notifications.service delete mode 100644 settings/accounts/services/mastodon-sharing.service create mode 100644 settings/accounts/ui/FediverseSettingsDisplay.qml delete mode 100644 settings/accounts/ui/MastodonSettingsDisplay.qml create mode 100644 settings/accounts/ui/fediverse-settings.qml create mode 100644 settings/accounts/ui/fediverse-update.qml create mode 100644 settings/accounts/ui/fediverse.qml delete mode 100644 settings/accounts/ui/mastodon-settings.qml delete mode 100644 settings/accounts/ui/mastodon-update.qml delete mode 100644 settings/accounts/ui/mastodon.qml create mode 100644 transferengine-plugins/fediverseshareplugin/FediverseSharePost.qml create mode 100644 transferengine-plugins/fediverseshareplugin/fediverseplugininfo.cpp create mode 100644 transferengine-plugins/fediverseshareplugin/fediverseplugininfo.h create mode 100644 transferengine-plugins/fediverseshareplugin/fediverseshareplugin.cpp create mode 100644 transferengine-plugins/fediverseshareplugin/fediverseshareplugin.h create mode 100644 transferengine-plugins/fediverseshareplugin/fediverseshareplugin.pro create mode 100644 transferengine-plugins/fediverseshareservicestatus.cpp create mode 100644 transferengine-plugins/fediverseshareservicestatus.h create mode 100644 transferengine-plugins/fediversetransferplugin/fediverseapi.cpp create mode 100644 transferengine-plugins/fediversetransferplugin/fediverseapi.h create mode 100644 transferengine-plugins/fediversetransferplugin/fediversetransferplugin.cpp create mode 100644 transferengine-plugins/fediversetransferplugin/fediversetransferplugin.h create mode 100644 transferengine-plugins/fediversetransferplugin/fediversetransferplugin.pro create mode 100644 transferengine-plugins/fediversetransferplugin/fediverseuploader.cpp create mode 100644 transferengine-plugins/fediversetransferplugin/fediverseuploader.h delete mode 100644 transferengine-plugins/mastodonshareplugin/MastodonSharePost.qml delete mode 100644 transferengine-plugins/mastodonshareplugin/mastodonplugininfo.cpp delete mode 100644 transferengine-plugins/mastodonshareplugin/mastodonplugininfo.h delete mode 100644 transferengine-plugins/mastodonshareplugin/mastodonshareplugin.cpp delete mode 100644 transferengine-plugins/mastodonshareplugin/mastodonshareplugin.h delete mode 100644 transferengine-plugins/mastodonshareplugin/mastodonshareplugin.pro delete mode 100644 transferengine-plugins/mastodonshareservicestatus.cpp delete mode 100644 transferengine-plugins/mastodonshareservicestatus.h delete mode 100644 transferengine-plugins/mastodontransferplugin/mastodonapi.cpp delete mode 100644 transferengine-plugins/mastodontransferplugin/mastodonapi.h delete mode 100644 transferengine-plugins/mastodontransferplugin/mastodontransferplugin.cpp delete mode 100644 transferengine-plugins/mastodontransferplugin/mastodontransferplugin.h delete mode 100644 transferengine-plugins/mastodontransferplugin/mastodontransferplugin.pro delete mode 100644 transferengine-plugins/mastodontransferplugin/mastodonuploader.cpp delete mode 100644 transferengine-plugins/mastodontransferplugin/mastodonuploader.h diff --git a/README.md b/README.md index 6ce2dfb..f8a1b97 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,73 @@ -# sailfish-account-mastodon +# sailfish-account-fediverse -Sailfish OS account integration for Mastodon. +Sailfish OS account integration for the Fediverse. -## Repository Components +## Repository components ### `common/` - Shared C++ library code used by multiple plugins. -- Includes socialcache-backed storage for Mastodon posts and shared Mastodon auth helpers. +- Includes socialcache-backed storage for Fediverse posts and shared OAuth helper code. ### `settings/` - Sailfish Accounts provider, service definitions, and account UI. -- OAuth2 (`web_server`) account flow with per-instance Mastodon app registration. -- Translations: - - QML translation-loader module at `/usr/lib*/qt5/qml/com/jolla/settings/accounts/mastodon/` loads `settings-accounts-mastodon` catalogs for `qsTrId` strings. - - Engineering English catalog: `/usr/share/translations/settings-accounts-mastodon_eng_en.qm` - - Translation source catalog: `/usr/share/translations/source/settings-accounts-mastodon.ts` - - Provider/service metadata uses `/usr/share/translations/settings-accounts-mastodon` for metadata string translation paths. -- Services: - - `mastodon-microblog`: sync service for posts and notifications. - - `mastodon-sharing`: Transfer Engine sharing service. +- OAuth2 (`web_server`) account flow with per-instance application registration. +- Discovers instance title, description, and icon from the instance entity. +- Caches remote instance icons and uses them in place of the generic Fediverse icon when available. +- Installs the QML translation-loader plugin under `/usr/lib*/qt5/qml/com/jolla/settings/accounts/fediverse/`. +- Installs `fediverse-microblog`, `fediverse-notifications`, and `fediverse-sharing` service definitions. ### `buteo-plugins/` -- Buteo sync plugins and shared social sync framework code. -- Includes: - - `buteo-sync-plugin-mastodon-posts` - - `buteo-sync-plugin-mastodon-notifications` -- Installs Buteo client profile and sync profile XML files. +- Shared Buteo sync framework support library. +- Sync plugins for Fediverse posts and notifications. +- Installs the Buteo client profile and sync profile XML files. ### `eventsview-plugins/` -- Events view extension for Mastodon posts. -- Includes delegate/feed item QML and `MastodonPostsModel`. +- Events view extension for Fediverse posts. +- Includes the delegate/feed item QML and `FediversePostsModel`. ### `transferengine-plugins/` -- Transfer Engine integration for Mastodon sharing. -- `mastodonshareplugin/`: sharing method discovery + metadata. -- `mastodontransferplugin/`: media upload + status creation. -- Single share UI entry: `MastodonSharePost.qml` handles both media and text/link posting. -- Supports: - - media sharing (`image/jpeg`, `image/png`, `video/mp4`) - - link/text sharing (`text/x-url`, `text/plain`) with title/link extraction from share resources. +- Transfer Engine integration for Fediverse sharing. +- `fediverseshareplugin/`: sharing method discovery and metadata. +- `fediversetransferplugin/`: media upload and status creation. +- `FediverseSharePost.qml` handles both media and text/link posting. ### `icons/` -- Mastodon SVG assets and `sailfish-svg2png` conversion setup. -- Uses canonical icon names only: - - `icons/icon-l-mastodon` +- Generic Fediverse SVG assets and `sailfish-svg2png` conversion setup. +- Uses the canonical icon name `icons/icon-l-fediverse`. + ### `rpm/` -- Packaging for all modules in `rpm/sailfish-account-mastodon.spec`. +- Packaging for all modules in `rpm/sailfish-account-fediverse.spec`. - Packages: - - `sailfish-account-mastodon` (all runtime components) - - `sailfish-account-mastodon-ts-devel` (translation source files only) -- `%qmake5_install` already installs icon outputs from the `icons/` subproject; avoid a second explicit `icons` `make install` in `%install`. -- Translation source `.ts` files are packaged in `sailfish-account-mastodon-ts-devel` (runtime package ships `.qm` only). -- Runtime package ships the Mastodon settings translation-loader QML plugin under `%{_libdir}/qt5/qml/com/jolla/settings/accounts/mastodon/`. + - `sailfish-account-fediverse` + - `sailfish-account-fediverse-ts-devel` +- `%qmake5_install` installs the icon outputs from the `icons/` subproject. +- Translation source `.ts` files are packaged separately from runtime `.qm` files. ### Root project -- `sailfish-account-mastodon.pro` ties subprojects together. +- `sailfish-account-fediverse.pro` ties the subprojects together. -## Current Notification Behavior +## Current behaviour -- Events view shows Mastodon posts (not notification entries). -- Events view post metadata line includes replies, favourites, and boosts alongside elapsed timestamp. -- Long-pressing a Mastodon post reveals quick actions for favourite and boost, calling Mastodon API endpoints directly with account OAuth credentials. -- System notifications are produced by `buteo-sync-plugin-mastodon-notifications`. -- Notifications sync starts from Mastodon server marker (`notifications.last_read_id`) and uses local cursor dedupe via per-account `LastFetchedNotificationId`. -- Each unread Mastodon notification is published as a separate Sailfish system notification. -- Mastodon marker (`last_read_id`) is updated only when no local Mastodon notifications remain for that account. -- Notification template profile dispatches per-account sync profiles on schedule (default every 30 minutes), not only at boot. +- The Events view shows Fediverse posts, not notification entries. +- The Events view metadata line includes replies, favourites, and boosts alongside elapsed time. +- Long-press actions call the instance API directly with the account OAuth credentials. +- System notifications are produced by the Fediverse notifications sync plugin. +- Notifications sync starts from the server marker (`notifications.last_read_id`) and uses a per-account local cursor for dedupe. +- Each unread notification is published as a separate Sailfish system notification. -## Build Requirements +## Build requirements This project targets Sailfish OS build tooling. -Full build/package validation is not possible without Sailfish SDK access (target sysroot + Sailfish packages). - -Required SDK-provided dependencies include (not exhaustive): +Required SDK-provided dependencies include: - `buteosyncfw5` - `socialcache` - `sailfishaccounts` - `nemotransferengine-qt5` -- related Qt/account stack packages listed in `rpm/sailfish-account-mastodon.spec` - -## Typical Build Flow (Inside Sailfish SDK) +- related Qt and Sailfish account stack packages listed in `rpm/sailfish-account-fediverse.spec` -1. Enter Sailfish SDK shell/target. -2. Build from repository root (`qmake` / `make`). -3. Build RPM package(s) from `rpm/sailfish-account-mastodon.spec`. +## Typical build flow -Outside Sailfish SDK, only static validation (wiring, paths, spec consistency) should be considered reliable. +1. Enter a Sailfish SDK shell or target. +2. Build from the repository root. +3. Build RPM packages from `rpm/sailfish-account-fediverse.spec`. diff --git a/buteo-plugins/buteo-common/buteo-common.pri b/buteo-plugins/buteo-common/buteo-common.pri index 83452ac..422d98f 100644 --- a/buteo-plugins/buteo-common/buteo-common.pri +++ b/buteo-plugins/buteo-common/buteo-common.pri @@ -10,4 +10,4 @@ QT += dbus CONFIG += link_pkgconfig PKGCONFIG += accounts-qt5 buteosyncfw5 socialcache libsignon-qt5 libsailfishkeyprovider -LIBS += -L$$PWD -lmastodonbuteocommon +LIBS += -L$$PWD -lfediversebuteocommon diff --git a/buteo-plugins/buteo-common/buteo-common.pro b/buteo-plugins/buteo-common/buteo-common.pro index c0b84a9..70a8332 100644 --- a/buteo-plugins/buteo-common/buteo-common.pro +++ b/buteo-plugins/buteo-common/buteo-common.pro @@ -4,7 +4,7 @@ TEMPLATE = lib -TARGET = mastodonbuteocommon +TARGET = fediversebuteocommon TARGET = $$qtLibraryTarget($$TARGET) QT -= gui diff --git a/buteo-plugins/buteo-plugins.pro b/buteo-plugins/buteo-plugins.pro index ead60e6..7938956 100644 --- a/buteo-plugins/buteo-plugins.pro +++ b/buteo-plugins/buteo-plugins.pro @@ -5,8 +5,8 @@ TEMPLATE = subdirs SUBDIRS += \ buteo-common \ - buteo-sync-plugin-mastodon-posts \ - buteo-sync-plugin-mastodon-notifications + buteo-sync-plugin-fediverse-posts \ + buteo-sync-plugin-fediverse-notifications -buteo-sync-plugin-mastodon-posts.depends = buteo-common -buteo-sync-plugin-mastodon-notifications.depends = buteo-common +buteo-sync-plugin-fediverse-posts.depends = buteo-common +buteo-sync-plugin-fediverse-notifications.depends = buteo-common 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 @@ + + + + + + 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 @@ + + + + + + + + + + + + + + + + + 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 +#include +#include + +// libaccounts-qt5 +#include +#include +#include +#include + +// libsignon-qt5 +#include +#include +#include + +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(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(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 &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(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(true)); + account->setValue(QStringLiteral("CredentialsNeedUpdateFrom"), QVariant::fromValue(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(account)); + session->setProperty("identity", QVariant::fromValue(identity)); + session->process(SignOn::SessionData(signonSessionData), mechanism); +} + +void FediverseNotificationsDataTypeSyncAdaptor::signOnError(const SignOn::Error &error) +{ + SignOn::AuthSession *session = qobject_cast(sender()); + Accounts::Account *account = session->property("account").value(); + SignOn::Identity *identity = session->property("identity").value(); + 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(sender()); + Accounts::Account *account = session->property("account").value(); + SignOn::Identity *identity = session->property("identity").value(); + 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 +#include +#include + +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 &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 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 +#include +#include + +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 + +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 +#include +#include +#include +#include +#include +#include +#include + +// libaccounts-qt5 +#include +#include +#include + +#include + +#include + +#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 notifications = Notification::notifications(); + foreach (QObject *object, notifications) { + Notification *notification = qobject_cast(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)), this, SLOT(sslErrorsHandler(QList))); + 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(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)), this, SLOT(sslErrorsHandler(QList))); + 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)), this, SLOT(sslErrorsHandler(QList))); + 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(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(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(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 &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 notifications = Notification::notifications(); + foreach (QObject *object, notifications) { + Notification *notification = qobject_cast(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 notifications = Notification::notifications(); + foreach (QObject *object, notifications) { + Notification *castedNotification = qobject_cast(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 +#include +#include +#include + +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 unreadNotificationIds; + QHash pendingNotifications; + }; + + static QString sanitizeContent(const QString &content); + static QDateTime parseTimestamp(const QString ×tampString); + 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 ¬ificationData); + Notification *createNotification(int accountId, const QString ¬ificationId); + Notification *findNotification(int accountId, const QString ¬ificationId); + void closeAccountNotifications(int accountId, const QSet &keepNotificationIds = QSet()); + static QString notificationObjectKey(int accountId, const QString ¬ificationId); + 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 m_accessTokens; + QHash m_pendingSyncStates; + QHash m_lastMarkedReadIds; + QHash m_notificationObjects; +}; + +#endif // FEDIVERSENOTIFICATIONSSYNCADAPTOR_H diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-posts/buteo-sync-plugin-fediverse-posts.pro b/buteo-plugins/buteo-sync-plugin-fediverse-posts/buteo-sync-plugin-fediverse-posts.pro new file mode 100644 index 0000000..d9936b0 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-fediverse-posts/buteo-sync-plugin-fediverse-posts.pro @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. +# +# SPDX-License-Identifier: BSD-3-Clause + +TARGET = fediverse-posts-client + +QT -= gui + +include($$PWD/../buteo-common/buteo-common.pri) +include($$PWD/../../common/common.pri) + +CONFIG += link_pkgconfig +PKGCONFIG += mlite5 nemonotifications-qt5 + +INCLUDEPATH += $$PWD + +SOURCES += \ + $$PWD/fediversedatatypesyncadaptor.cpp \ + $$PWD/fediversepostsplugin.cpp \ + $$PWD/fediversepostssyncadaptor.cpp + +HEADERS += \ + $$PWD/fediversedatatypesyncadaptor.h \ + $$PWD/fediversepostsplugin.h \ + $$PWD/fediversepostssyncadaptor.h + +OTHER_FILES += \ + $$PWD/fediverse-posts.xml \ + $$PWD/fediverse.Posts.xml + +TEMPLATE = lib +CONFIG += plugin +target.path = $$[QT_INSTALL_LIBS]/buteo-plugins-qt5/oopp + +sync.path = /etc/buteo/profiles/sync +sync.files = fediverse.Posts.xml + +client.path = /etc/buteo/profiles/client +client.files = fediverse-posts.xml + +INSTALLS += target sync client diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediverse-posts.xml b/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediverse-posts.xml new file mode 100644 index 0000000..4397ff0 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediverse-posts.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediverse.Posts.xml b/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediverse.Posts.xml new file mode 100644 index 0000000..6601313 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediverse.Posts.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversedatatypesyncadaptor.cpp b/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversedatatypesyncadaptor.cpp new file mode 100644 index 0000000..3ef6f35 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-fediverse-posts/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 +#include +#include + +// libaccounts-qt5 +#include +#include +#include +#include + +// libsignon-qt5 +#include +#include +#include + +Q_LOGGING_CATEGORY(lcFediverseSync, "buteo.plugin.fediverse.sync", QtWarningMsg) + +FediverseDataTypeSyncAdaptor::FediverseDataTypeSyncAdaptor( + SocialNetworkSyncAdaptor::DataType dataType, + QObject *parent) + : SocialNetworkSyncAdaptor(QStringLiteral("fediverse"), dataType, 0, parent) +{ +} + +FediverseDataTypeSyncAdaptor::~FediverseDataTypeSyncAdaptor() +{ +} + +void FediverseDataTypeSyncAdaptor::sync(const QString &dataTypeString, int accountId) +{ + if (dataTypeString != SocialNetworkSyncAdaptor::dataTypeName(m_dataType)) { + qCWarning(lcFediverseSync) << "Fediverse" << SocialNetworkSyncAdaptor::dataTypeName(m_dataType) + << "sync adaptor was asked to sync" << dataTypeString; + setStatus(SocialNetworkSyncAdaptor::Error); + return; + } + + setStatus(SocialNetworkSyncAdaptor::Busy); + updateDataForAccount(accountId); + qCDebug(lcFediverseSync) << "successfully triggered sync with profile:" << m_accountSyncProfile->name(); +} + +void FediverseDataTypeSyncAdaptor::updateDataForAccount(int accountId) +{ + Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this); + if (!account) { + qCWarning(lcFediverseSync) << "existing account with id" << accountId << "couldn't be retrieved"; + setStatus(SocialNetworkSyncAdaptor::Error); + return; + } + + incrementSemaphore(accountId); + signIn(account); +} + +QString FediverseDataTypeSyncAdaptor::apiHost(int accountId) const +{ + return m_apiHosts.value(accountId, FediverseAuthUtils::defaultApiHost()); +} + +QString FediverseDataTypeSyncAdaptor::iconPath(int accountId) const +{ + return m_iconPaths.value(accountId, QStringLiteral("image://theme/icon-l-fediverse")); +} + +void FediverseDataTypeSyncAdaptor::errorHandler(QNetworkReply::NetworkError err) +{ + QNetworkReply *reply = qobject_cast(sender()); + if (!reply) { + return; + } + + const int accountId = reply->property("accountId").toInt(); + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + qCWarning(lcFediverseSync) << SocialNetworkSyncAdaptor::dataTypeName(m_dataType) + << "request with account" << accountId + << "experienced error:" << err + << "HTTP:" << httpStatus; + + reply->setProperty("isError", QVariant::fromValue(true)); + + if (httpStatus == 401 || err == QNetworkReply::AuthenticationRequiredError) { + Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this); + if (account) { + setCredentialsNeedUpdate(account); + } + } +} + +void FediverseDataTypeSyncAdaptor::sslErrorsHandler(const QList &errs) +{ + QString sslerrs; + foreach (const QSslError &e, errs) { + sslerrs += e.errorString() + QLatin1String("; "); + } + if (!sslerrs.isEmpty()) { + sslerrs.chop(2); + } + + qCWarning(lcFediverseSync) << SocialNetworkSyncAdaptor::dataTypeName(m_dataType) + << "request with account" << sender()->property("accountId").toInt() + << "experienced ssl errors:" << sslerrs; + sender()->setProperty("isError", QVariant::fromValue(true)); +} + +void FediverseDataTypeSyncAdaptor::setCredentialsNeedUpdate(Accounts::Account *account) +{ + qCInfo(lcFediverseSync) << "sociald:Fediverse: setting CredentialsNeedUpdate to true for account:" << account->id(); + Accounts::Service srv(m_accountManager->service(syncServiceName())); + account->selectService(srv); + account->setValue(QStringLiteral("CredentialsNeedUpdate"), QVariant::fromValue(true)); + account->setValue(QStringLiteral("CredentialsNeedUpdateFrom"), QVariant::fromValue(QString::fromLatin1("sociald-fediverse"))); + account->selectService(Accounts::Service()); + account->syncAndBlock(); +} + +void FediverseDataTypeSyncAdaptor::signIn(Accounts::Account *account) +{ + const int accountId = account->id(); + if (!checkAccount(account)) { + decrementSemaphore(accountId); + return; + } + + Accounts::Service srv(m_accountManager->service(syncServiceName())); + account->selectService(srv); + SignOn::Identity *identity = account->credentialsId() > 0 + ? SignOn::Identity::existingIdentity(account->credentialsId()) + : 0; + if (!identity) { + qCWarning(lcFediverseSync) << "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(lcFediverseSync) << "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(account)); + session->setProperty("identity", QVariant::fromValue(identity)); + session->process(SignOn::SessionData(signonSessionData), mechanism); +} + +void FediverseDataTypeSyncAdaptor::signOnError(const SignOn::Error &error) +{ + SignOn::AuthSession *session = qobject_cast(sender()); + Accounts::Account *account = session->property("account").value(); + SignOn::Identity *identity = session->property("identity").value(); + const int accountId = account->id(); + + qCWarning(lcFediverseSync) << "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 FediverseDataTypeSyncAdaptor::signOnResponse(const SignOn::SessionData &responseData) +{ + const QVariantMap data = FediverseAuthUtils::responseDataToMap(responseData); + + QString accessToken; + SignOn::AuthSession *session = qobject_cast(sender()); + Accounts::Account *account = session->property("account").value(); + SignOn::Identity *identity = session->property("identity").value(); + const int accountId = account->id(); + + accessToken = FediverseAuthUtils::accessToken(data); + if (accessToken.isEmpty()) { + qCWarning(lcFediverseSync) << "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())); + m_iconPaths.insert(accountId, account->value(QStringLiteral("iconPath")).toString().trimmed()); + + 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-posts/fediversedatatypesyncadaptor.h b/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversedatatypesyncadaptor.h new file mode 100644 index 0000000..4511a26 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversedatatypesyncadaptor.h @@ -0,0 +1,71 @@ +/**************************************************************************** + ** + ** 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 FEDIVERSEDATATYPESYNCADAPTOR_H +#define FEDIVERSEDATATYPESYNCADAPTOR_H + +#include "socialnetworksyncadaptor.h" + +#include +#include +#include + +namespace Accounts { + class Account; +} +namespace SignOn { + class Error; + class SessionData; +} + +class FediverseDataTypeSyncAdaptor : public SocialNetworkSyncAdaptor +{ + Q_OBJECT + +public: + FediverseDataTypeSyncAdaptor(SocialNetworkSyncAdaptor::DataType dataType, QObject *parent); + virtual ~FediverseDataTypeSyncAdaptor(); + + void sync(const QString &dataTypeString, int accountId) override; + +protected: + QString apiHost(int accountId) const; + QString iconPath(int accountId) const; + virtual void updateDataForAccount(int accountId); + virtual void beginSync(int accountId, const QString &accessToken) = 0; + +protected Q_SLOTS: + virtual void errorHandler(QNetworkReply::NetworkError err); + virtual void sslErrorsHandler(const QList &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 m_apiHosts; + QMap m_iconPaths; +}; + +#endif // FEDIVERSEDATATYPESYNCADAPTOR_H diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostsplugin.cpp b/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostsplugin.cpp new file mode 100644 index 0000000..c794c00 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostsplugin.cpp @@ -0,0 +1,49 @@ +/**************************************************************************** + ** + ** 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 "fediversepostsplugin.h" +#include "fediversepostssyncadaptor.h" +#include "socialnetworksyncadaptor.h" + +FediversePostsPlugin::FediversePostsPlugin(const QString& pluginName, + const Buteo::SyncProfile& profile, + Buteo::PluginCbInterface *callbackInterface) + : SocialdButeoPlugin(pluginName, profile, callbackInterface, + QStringLiteral("fediverse"), + SocialNetworkSyncAdaptor::dataTypeName(SocialNetworkSyncAdaptor::Posts)) +{ +} + +FediversePostsPlugin::~FediversePostsPlugin() +{ +} + +SocialNetworkSyncAdaptor *FediversePostsPlugin::createSocialNetworkSyncAdaptor() +{ + return new FediversePostsSyncAdaptor(this); +} + +Buteo::ClientPlugin* FediversePostsPluginLoader::createClientPlugin( + const QString& pluginName, + const Buteo::SyncProfile& profile, + Buteo::PluginCbInterface* cbInterface) +{ + return new FediversePostsPlugin(pluginName, profile, cbInterface); +} diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostsplugin.h b/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostsplugin.h new file mode 100644 index 0000000..933cd97 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostsplugin.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 FEDIVERSEPOSTSPLUGIN_H +#define FEDIVERSEPOSTSPLUGIN_H + +#include "socialdbuteoplugin.h" + +#include + +class Q_DECL_EXPORT FediversePostsPlugin : public SocialdButeoPlugin +{ + Q_OBJECT + +public: + FediversePostsPlugin(const QString& pluginName, + const Buteo::SyncProfile& profile, + Buteo::PluginCbInterface *cbInterface); + ~FediversePostsPlugin(); + +protected: + SocialNetworkSyncAdaptor *createSocialNetworkSyncAdaptor() override; +}; + +class FediversePostsPluginLoader : public Buteo::SyncPluginLoader +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.sailfishos.plugins.sync.FediversePostsPluginLoader") + Q_INTERFACES(Buteo::SyncPluginLoader) + +public: + Buteo::ClientPlugin* createClientPlugin(const QString& pluginName, + const Buteo::SyncProfile& profile, + Buteo::PluginCbInterface* cbInterface) override; +}; + +#endif // FEDIVERSEPOSTSPLUGIN_H diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostssyncadaptor.cpp b/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostssyncadaptor.cpp new file mode 100644 index 0000000..59e37bf --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostssyncadaptor.cpp @@ -0,0 +1,260 @@ +/**************************************************************************** + ** + ** 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 "fediversepostssyncadaptor.h" +#include "fediversetextutils.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + Q_LOGGING_CATEGORY(lcFediversePostsSync, "buteo.plugin.fediverse.posts.sync", QtWarningMsg) + + 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(); + } +} + +FediversePostsSyncAdaptor::FediversePostsSyncAdaptor(QObject *parent) + : FediverseDataTypeSyncAdaptor(SocialNetworkSyncAdaptor::Posts, parent) +{ + setInitialActive(m_db.isValid()); +} + +FediversePostsSyncAdaptor::~FediversePostsSyncAdaptor() +{ +} + +QString FediversePostsSyncAdaptor::syncServiceName() const +{ + return QStringLiteral("fediverse-microblog"); +} + +void FediversePostsSyncAdaptor::purgeDataForOldAccount(int oldId, SocialNetworkSyncAdaptor::PurgeMode) +{ + m_db.removePosts(oldId); + m_db.commit(); + m_db.wait(); + m_db.refresh(); + m_db.wait(); + + purgeCachedImages(&m_imageCacheDb, oldId); +} + +void FediversePostsSyncAdaptor::beginSync(int accountId, const QString &accessToken) +{ + requestPosts(accountId, accessToken); +} + +void FediversePostsSyncAdaptor::finalize(int accountId) +{ + if (syncAborted()) { + qCInfo(lcFediversePostsSync) << "sync aborted, won't commit database changes"; + } else { + m_db.commit(); + m_db.wait(); + m_db.refresh(); + m_db.wait(); + purgeExpiredImages(&m_imageCacheDb, accountId); + } +} + +QString FediversePostsSyncAdaptor::sanitizeContent(const QString &content) +{ + return FediverseTextUtils::sanitizeContent(content); +} + +QDateTime FediversePostsSyncAdaptor::parseTimestamp(const QString ×tampString) +{ + return FediverseTextUtils::parseTimestamp(timestampString); +} + +void FediversePostsSyncAdaptor::requestPosts(int accountId, const QString &accessToken) +{ + QUrl url(apiHost(accountId) + QStringLiteral("/api/v1/timelines/home")); + + QUrlQuery query(url); + query.addQueryItem(QStringLiteral("limit"), QStringLiteral("20")); + 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); + connect(reply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(errorHandler(QNetworkReply::NetworkError))); + connect(reply, SIGNAL(sslErrors(QList)), this, SLOT(sslErrorsHandler(QList))); + connect(reply, SIGNAL(finished()), this, SLOT(finishedPostsHandler())); + + incrementSemaphore(accountId); + setupReplyTimeout(accountId, reply); + } else { + qCWarning(lcFediversePostsSync) << "unable to request home timeline posts from Fediverse account with id" << accountId; + } +} + +void FediversePostsSyncAdaptor::finishedPostsHandler() +{ + QNetworkReply *reply = qobject_cast(sender()); + if (!reply) { + return; + } + + const bool isError = reply->property("isError").toBool(); + const int accountId = reply->property("accountId").toInt(); + QByteArray replyData = reply->readAll(); + + disconnect(reply); + reply->deleteLater(); + removeReplyTimeout(accountId, reply); + + bool ok = false; + QJsonArray statuses = parseJsonArrayReplyData(replyData, &ok); + if (!isError && ok) { + m_db.removePosts(accountId); + + if (!statuses.size()) { + qCDebug(lcFediversePostsSync) << "no feed posts received for account" << accountId; + decrementSemaphore(accountId); + return; + } + + const int sinceSpan = m_accountSyncProfile + ? m_accountSyncProfile->key(Buteo::KEY_SYNC_SINCE_DAYS_PAST, QStringLiteral("7")).toInt() + : 7; + + foreach (const QJsonValue &statusValue, statuses) { + const QJsonObject statusObject = statusValue.toObject(); + if (statusObject.isEmpty()) { + continue; + } + + QJsonObject postObject = statusObject; + QString boostedBy; + if (statusObject.contains(QStringLiteral("reblog")) + && statusObject.value(QStringLiteral("reblog")).isObject() + && !statusObject.value(QStringLiteral("reblog")).isNull()) { + boostedBy = displayNameForAccount(statusObject.value(QStringLiteral("account")).toObject()); + postObject = statusObject.value(QStringLiteral("reblog")).toObject(); + } + + QDateTime eventTimestamp = parseTimestamp(statusObject.value(QStringLiteral("created_at")).toString()); + if (!eventTimestamp.isValid()) { + eventTimestamp = parseTimestamp(postObject.value(QStringLiteral("created_at")).toString()); + } + if (!eventTimestamp.isValid()) { + continue; + } + + if (eventTimestamp.daysTo(QDateTime::currentDateTime()) > sinceSpan) { + continue; + } + + const QJsonObject account = postObject.value(QStringLiteral("account")).toObject(); + const QString displayName = displayNameForAccount(account); + const QString accountName = account.value(QStringLiteral("acct")).toString(); + QString icon = account.value(QStringLiteral("avatar_static")).toString(); + if (icon.isEmpty()) { + icon = account.value(QStringLiteral("avatar")).toString(); + } + + QString identifier = postObject.value(QStringLiteral("id")).toVariant().toString(); + if (identifier.isEmpty()) { + continue; + } + + QString url = postObject.value(QStringLiteral("url")).toString(); + if (url.isEmpty() && !accountName.isEmpty()) { + url = QStringLiteral("%1/@%2/%3").arg(apiHost(accountId), accountName, identifier); + } + + const QString body = sanitizeContent(postObject.value(QStringLiteral("content")).toString()); + const int repliesCount = postObject.value(QStringLiteral("replies_count")).toInt(); + const int favouritesCount = postObject.value(QStringLiteral("favourites_count")).toInt(); + const int reblogsCount = postObject.value(QStringLiteral("reblogs_count")).toInt(); + const bool favourited = postObject.value(QStringLiteral("favourited")).toBool(); + const bool reblogged = postObject.value(QStringLiteral("reblogged")).toBool(); + + QList > imageList; + const QJsonArray mediaAttachments = postObject.value(QStringLiteral("media_attachments")).toArray(); + foreach (const QJsonValue &attachmentValue, mediaAttachments) { + const QJsonObject attachment = attachmentValue.toObject(); + const QString mediaType = attachment.value(QStringLiteral("type")).toString(); + + QString mediaUrl; + SocialPostImage::ImageType imageType = SocialPostImage::Invalid; + if (mediaType == QLatin1String("image")) { + mediaUrl = attachment.value(QStringLiteral("url")).toString(); + imageType = SocialPostImage::Photo; + } else if (mediaType == QLatin1String("video") || mediaType == QLatin1String("gifv")) { + mediaUrl = attachment.value(QStringLiteral("preview_url")).toString(); + if (mediaUrl.isEmpty()) { + mediaUrl = attachment.value(QStringLiteral("url")).toString(); + } + imageType = SocialPostImage::Video; + } + + if (!mediaUrl.isEmpty() && imageType != SocialPostImage::Invalid) { + imageList.append(qMakePair(mediaUrl, imageType)); + } + } + + m_db.addFediversePost(identifier, + displayName, + accountName, + body, + eventTimestamp, + icon, + imageList, + url, + boostedBy, + repliesCount, + favouritesCount, + reblogsCount, + favourited, + reblogged, + apiHost(accountId), + iconPath(accountId), + accountId); + } + } else { + qCWarning(lcFediversePostsSync) << "unable to parse event feed data from request with account" << accountId + << ", got:" << QString::fromUtf8(replyData); + } + + decrementSemaphore(accountId); +} diff --git a/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostssyncadaptor.h b/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostssyncadaptor.h new file mode 100644 index 0000000..341e049 --- /dev/null +++ b/buteo-plugins/buteo-sync-plugin-fediverse-posts/fediversepostssyncadaptor.h @@ -0,0 +1,61 @@ +/**************************************************************************** + ** + ** 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 FEDIVERSEPOSTSSYNCADAPTOR_H +#define FEDIVERSEPOSTSSYNCADAPTOR_H + +#include "fediversedatatypesyncadaptor.h" + +#include +#include + +#include "fediversepostsdatabase.h" +#include + +class FediversePostsSyncAdaptor : public FediverseDataTypeSyncAdaptor +{ + Q_OBJECT + +public: + FediversePostsSyncAdaptor(QObject *parent); + ~FediversePostsSyncAdaptor(); + + QString syncServiceName() const override; + +protected: + void purgeDataForOldAccount(int oldId, SocialNetworkSyncAdaptor::PurgeMode mode) override; + void beginSync(int accountId, const QString &accessToken) override; + void finalize(int accountId) override; + +private: + static QString sanitizeContent(const QString &content); + static QDateTime parseTimestamp(const QString ×tampString); + + void requestPosts(int accountId, const QString &accessToken); + +private Q_SLOTS: + void finishedPostsHandler(); + +private: + FediversePostsDatabase m_db; + SocialImagesDatabase m_imageCacheDb; +}; + +#endif // FEDIVERSEPOSTSSYNCADAPTOR_H diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/buteo-sync-plugin-mastodon-notifications.pro b/buteo-plugins/buteo-sync-plugin-mastodon-notifications/buteo-sync-plugin-mastodon-notifications.pro deleted file mode 100644 index d16cc3d..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/buteo-sync-plugin-mastodon-notifications.pro +++ /dev/null @@ -1,66 +0,0 @@ -# SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. -# -# SPDX-License-Identifier: BSD-3-Clause - -TARGET = mastodon-notifications-client - -QT -= gui - -include($$PWD/../buteo-common/buteo-common.pri) -include($$PWD/../../common/common.pri) - -TS_FILE = $$OUT_PWD/lipstick-jolla-home-mastodon-notifications.ts -EE_QM = $$OUT_PWD/lipstick-jolla-home-mastodon-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/mastodondatatypesyncadaptor.cpp \ - $$PWD/mastodonnotificationsplugin.cpp \ - $$PWD/mastodonnotificationssyncadaptor.cpp - -HEADERS += \ - $$PWD/mastodondatatypesyncadaptor.h \ - $$PWD/mastodonnotificationsplugin.h \ - $$PWD/mastodonnotificationssyncadaptor.h - -OTHER_FILES += \ - $$PWD/mastodon-notifications.xml \ - $$PWD/mastodon.Notifications.xml - -TEMPLATE = lib -CONFIG += plugin -target.path = $$[QT_INSTALL_LIBS]/buteo-plugins-qt5/oopp - -sync.path = /etc/buteo/profiles/sync -sync.files = mastodon.Notifications.xml - -client.path = /etc/buteo/profiles/client -client.files = mastodon-notifications.xml - -INSTALLS += target sync client ts_install engineering_english_install diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodon-notifications.xml b/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodon-notifications.xml deleted file mode 100644 index 3284d61..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodon-notifications.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodon.Notifications.xml b/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodon.Notifications.xml deleted file mode 100644 index 05d5218..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodon.Notifications.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodondatatypesyncadaptor.cpp b/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodondatatypesyncadaptor.cpp deleted file mode 100644 index ddf6686..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodondatatypesyncadaptor.cpp +++ /dev/null @@ -1,240 +0,0 @@ -/**************************************************************************** - ** - ** 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 "mastodondatatypesyncadaptor.h" -#include "mastodonauthutils.h" - -#include -#include -#include - -// libaccounts-qt5 -#include -#include -#include -#include - -// libsignon-qt5 -#include -#include -#include - -Q_LOGGING_CATEGORY(lcMastodonNotificationsSync, "buteo.plugin.mastodon.notifications.sync", QtWarningMsg) - -MastodonNotificationsDataTypeSyncAdaptor::MastodonNotificationsDataTypeSyncAdaptor( - SocialNetworkSyncAdaptor::DataType dataType, - QObject *parent) - : SocialNetworkSyncAdaptor(QStringLiteral("mastodon"), dataType, 0, parent) -{ -} - -MastodonNotificationsDataTypeSyncAdaptor::~MastodonNotificationsDataTypeSyncAdaptor() -{ -} - -void MastodonNotificationsDataTypeSyncAdaptor::sync(const QString &dataTypeString, int accountId) -{ - if (dataTypeString != SocialNetworkSyncAdaptor::dataTypeName(m_dataType)) { - qCWarning(lcMastodonNotificationsSync) << "Mastodon" << SocialNetworkSyncAdaptor::dataTypeName(m_dataType) - << "sync adaptor was asked to sync" << dataTypeString; - setStatus(SocialNetworkSyncAdaptor::Error); - return; - } - - setStatus(SocialNetworkSyncAdaptor::Busy); - updateDataForAccount(accountId); - qCDebug(lcMastodonNotificationsSync) << "successfully triggered sync with profile:" << m_accountSyncProfile->name(); -} - -void MastodonNotificationsDataTypeSyncAdaptor::updateDataForAccount(int accountId) -{ - Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this); - if (!account) { - qCWarning(lcMastodonNotificationsSync) << "existing account with id" << accountId << "couldn't be retrieved"; - setStatus(SocialNetworkSyncAdaptor::Error); - return; - } - - incrementSemaphore(accountId); - signIn(account); -} - -QString MastodonNotificationsDataTypeSyncAdaptor::apiHost(int accountId) const -{ - return m_apiHosts.value(accountId, QStringLiteral("https://mastodon.social")); -} - -QString MastodonNotificationsDataTypeSyncAdaptor::authServiceName() const -{ - return syncServiceName(); -} - -void MastodonNotificationsDataTypeSyncAdaptor::errorHandler(QNetworkReply::NetworkError err) -{ - QNetworkReply *reply = qobject_cast(sender()); - if (!reply) { - return; - } - - const int accountId = reply->property("accountId").toInt(); - const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - - qCWarning(lcMastodonNotificationsSync) << SocialNetworkSyncAdaptor::dataTypeName(m_dataType) - << "request with account" << accountId - << "experienced error:" << err - << "HTTP:" << httpStatus; - - reply->setProperty("isError", QVariant::fromValue(true)); - - if (httpStatus == 401 || err == QNetworkReply::AuthenticationRequiredError) { - Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this); - if (account) { - setCredentialsNeedUpdate(account); - } - } -} - -void MastodonNotificationsDataTypeSyncAdaptor::sslErrorsHandler(const QList &errs) -{ - QString sslerrs; - foreach (const QSslError &e, errs) { - sslerrs += e.errorString() + QLatin1String("; "); - } - if (!sslerrs.isEmpty()) { - sslerrs.chop(2); - } - - qCWarning(lcMastodonNotificationsSync) << SocialNetworkSyncAdaptor::dataTypeName(m_dataType) - << "request with account" << sender()->property("accountId").toInt() - << "experienced ssl errors:" << sslerrs; - sender()->setProperty("isError", QVariant::fromValue(true)); -} - -void MastodonNotificationsDataTypeSyncAdaptor::setCredentialsNeedUpdate(Accounts::Account *account) -{ - qCInfo(lcMastodonNotificationsSync) << "sociald:Mastodon: setting CredentialsNeedUpdate to true for account:" << account->id(); - Accounts::Service srv(m_accountManager->service(authServiceName())); - account->selectService(srv); - account->setValue(QStringLiteral("CredentialsNeedUpdate"), QVariant::fromValue(true)); - account->setValue(QStringLiteral("CredentialsNeedUpdateFrom"), QVariant::fromValue(QString::fromLatin1("sociald-mastodon"))); - account->selectService(Accounts::Service()); - account->syncAndBlock(); -} - -void MastodonNotificationsDataTypeSyncAdaptor::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(lcMastodonNotificationsSync) << "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(lcMastodonNotificationsSync) << "could not create signon session for account" << accountId; - identity->deleteLater(); - decrementSemaphore(accountId); - return; - } - - QVariantMap signonSessionData = accSrv.authData().parameters(); - MastodonAuthUtils::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(account)); - session->setProperty("identity", QVariant::fromValue(identity)); - session->process(SignOn::SessionData(signonSessionData), mechanism); -} - -void MastodonNotificationsDataTypeSyncAdaptor::signOnError(const SignOn::Error &error) -{ - SignOn::AuthSession *session = qobject_cast(sender()); - Accounts::Account *account = session->property("account").value(); - SignOn::Identity *identity = session->property("identity").value(); - const int accountId = account->id(); - - qCWarning(lcMastodonNotificationsSync) << "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 MastodonNotificationsDataTypeSyncAdaptor::signOnResponse(const SignOn::SessionData &responseData) -{ - const QVariantMap data = MastodonAuthUtils::responseDataToMap(responseData); - - QString accessToken; - SignOn::AuthSession *session = qobject_cast(sender()); - Accounts::Account *account = session->property("account").value(); - SignOn::Identity *identity = session->property("identity").value(); - const int accountId = account->id(); - - accessToken = MastodonAuthUtils::accessToken(data); - if (accessToken.isEmpty()) { - qCWarning(lcMastodonNotificationsSync) << "signon response for account with id" << accountId - << "contained no access token; keys:" << data.keys(); - } - - m_apiHosts.insert(accountId, MastodonAuthUtils::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-mastodon-notifications/mastodondatatypesyncadaptor.h b/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodondatatypesyncadaptor.h deleted file mode 100644 index 3c61ade..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodondatatypesyncadaptor.h +++ /dev/null @@ -1,70 +0,0 @@ -/**************************************************************************** - ** - ** 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 MASTODONNOTIFICATIONSDATATYPESYNCADAPTOR_H -#define MASTODONNOTIFICATIONSDATATYPESYNCADAPTOR_H - -#include "socialnetworksyncadaptor.h" - -#include -#include -#include - -namespace Accounts { - class Account; -} -namespace SignOn { - class Error; - class SessionData; -} - -class MastodonNotificationsDataTypeSyncAdaptor : public SocialNetworkSyncAdaptor -{ - Q_OBJECT - -public: - MastodonNotificationsDataTypeSyncAdaptor(SocialNetworkSyncAdaptor::DataType dataType, QObject *parent); - virtual ~MastodonNotificationsDataTypeSyncAdaptor(); - - 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 &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 m_apiHosts; -}; - -#endif // MASTODONNOTIFICATIONSDATATYPESYNCADAPTOR_H diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodonnotificationsplugin.cpp b/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodonnotificationsplugin.cpp deleted file mode 100644 index 9dd3724..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodonnotificationsplugin.cpp +++ /dev/null @@ -1,96 +0,0 @@ -/**************************************************************************** - ** - ** 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 "mastodonnotificationsplugin.h" -#include "mastodonnotificationssyncadaptor.h" -#include "socialnetworksyncadaptor.h" - -#include -#include -#include - -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-mastodon-notifications_eng_en"), - QStringLiteral("/usr/share/translations")); - - AppTranslator *translator = new AppTranslator(app); - translator->load(QLocale(), - QStringLiteral("lipstick-jolla-home-mastodon-notifications"), - QStringLiteral("-"), - QStringLiteral("/usr/share/translations")); - - initialized = true; -} -} - -MastodonNotificationsPlugin::MastodonNotificationsPlugin(const QString& pluginName, - const Buteo::SyncProfile& profile, - Buteo::PluginCbInterface *callbackInterface) - : SocialdButeoPlugin(pluginName, profile, callbackInterface, - QStringLiteral("mastodon"), - SocialNetworkSyncAdaptor::dataTypeName(SocialNetworkSyncAdaptor::Notifications)) -{ - ensureNotificationTranslations(); -} - -MastodonNotificationsPlugin::~MastodonNotificationsPlugin() -{ -} - -SocialNetworkSyncAdaptor *MastodonNotificationsPlugin::createSocialNetworkSyncAdaptor() -{ - return new MastodonNotificationsSyncAdaptor(this); -} - -Buteo::ClientPlugin* MastodonNotificationsPluginLoader::createClientPlugin( - const QString& pluginName, - const Buteo::SyncProfile& profile, - Buteo::PluginCbInterface* cbInterface) -{ - return new MastodonNotificationsPlugin(pluginName, profile, cbInterface); -} diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodonnotificationsplugin.h b/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodonnotificationsplugin.h deleted file mode 100644 index a5a7b37..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodonnotificationsplugin.h +++ /dev/null @@ -1,54 +0,0 @@ -/**************************************************************************** - ** - ** 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 MASTODONNOTIFICATIONSPLUGIN_H -#define MASTODONNOTIFICATIONSPLUGIN_H - -#include "socialdbuteoplugin.h" - -#include - -class Q_DECL_EXPORT MastodonNotificationsPlugin : public SocialdButeoPlugin -{ - Q_OBJECT - -public: - MastodonNotificationsPlugin(const QString& pluginName, - const Buteo::SyncProfile& profile, - Buteo::PluginCbInterface *cbInterface); - ~MastodonNotificationsPlugin(); - -protected: - SocialNetworkSyncAdaptor *createSocialNetworkSyncAdaptor() override; -}; - -class MastodonNotificationsPluginLoader : public Buteo::SyncPluginLoader -{ - Q_OBJECT - Q_PLUGIN_METADATA(IID "org.sailfishos.plugins.sync.MastodonNotificationsPluginLoader") - Q_INTERFACES(Buteo::SyncPluginLoader) - -public: - Buteo::ClientPlugin* createClientPlugin(const QString& pluginName, - const Buteo::SyncProfile& profile, - Buteo::PluginCbInterface* cbInterface) override; -}; - -#endif // MASTODONNOTIFICATIONSPLUGIN_H diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodonnotificationssyncadaptor.cpp b/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodonnotificationssyncadaptor.cpp deleted file mode 100644 index 98dbbc8..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodonnotificationssyncadaptor.cpp +++ /dev/null @@ -1,970 +0,0 @@ -/**************************************************************************** - ** - ** 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 "mastodonnotificationssyncadaptor.h" -#include "mastodontextutils.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -// libaccounts-qt5 -#include -#include -#include - -#include - -#include - -#define OPEN_URL_ACTION(openUrl) \ - Notification::remoteAction( \ - "default", \ - "", \ - "org.sailfishos.fileservice", \ - "/", \ - "org.sailfishos.fileservice", \ - "openUrl", \ - QVariantList() << openUrl \ - ) - -namespace { - Q_LOGGING_CATEGORY(lcMastodonNotifications, "buteo.plugin.mastodon.notifications", QtWarningMsg) - - const char *const NotificationCategory = "x-nemo.social.mastodon.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-mastodon-notification-mentioned_you"); - //% "boosted your post" - const char *const TrIdBoostedYourPost = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-boosted_your_post"); - //% "favourited your post" - const char *const TrIdFavouritedYourPost = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-favourited_your_post"); - //% "started following you" - const char *const TrIdStartedFollowingYou = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-started_following_you"); - //% "requested to follow you" - const char *const TrIdRequestedToFollowYou = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-requested_to_follow_you"); - //% "interacted with your poll" - const char *const TrIdInteractedWithYourPoll = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-interacted_with_your_poll"); - //% "posted" - const char *const TrIdPosted = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-posted"); - //% "updated a post" - const char *const TrIdUpdatedPost = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-updated_post"); - //% "signed up" - const char *const TrIdSignedUp = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-signed_up"); - //% "reported an account" - const char *const TrIdReportedAccount = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-reported_account"); - //% "received a moderation warning" - const char *const TrIdReceivedModerationWarning = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-received_moderation_warning"); - //% "quoted your post" - const char *const TrIdQuotedYourPost = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-quoted_your_post"); - //% "updated a post that quoted you" - const char *const TrIdUpdatedQuotedPost = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-updated_quoted_post"); - //% "sent you a notification" - const char *const TrIdSentNotification = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-sent_notification"); - - //% "An admin blocked an instance" - const char *const TrIdAdminBlockedInstance = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-admin_blocked_instance"); - //% "An admin blocked %1" - const char *const TrIdAdminBlockedTarget = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-admin_blocked_target"); - //% "You blocked an instance" - const char *const TrIdYouBlockedInstance = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-you_blocked_instance"); - //% "You blocked %1" - const char *const TrIdYouBlockedTarget = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-you_blocked_target"); - //% "An account was suspended" - const char *const TrIdAccountSuspended = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-account_suspended"); - //% "%1 was suspended" - const char *const TrIdTargetSuspended = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-target_suspended"); - //% "Some follow relationships were severed" - const char *const TrIdRelationshipsSevered = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-relationships_severed"); - //% "%1 (%2 followers, %3 following removed)" - const char *const TrIdRelationshipsSummary = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-relationships_summary"); - - //% "A moderator sent you a warning" - const char *const TrIdModeratorWarningNone = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-moderator_warning_none"); - //% "A moderator disabled your account" - const char *const TrIdModeratorWarningDisable = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-moderator_warning_disable"); - //% "A moderator marked specific posts as sensitive" - const char *const TrIdModeratorWarningSpecificSensitive = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-moderator_warning_specific_sensitive"); - //% "A moderator deleted specific posts" - const char *const TrIdModeratorWarningDeletePosts = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-moderator_warning_delete_posts"); - //% "A moderator marked all your posts as sensitive" - const char *const TrIdModeratorWarningAllSensitive = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-moderator_warning_all_sensitive"); - //% "A moderator limited your account" - const char *const TrIdModeratorWarningSilence = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-moderator_warning_silence"); - //% "A moderator suspended your account" - const char *const TrIdModeratorWarningSuspend = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-moderator_warning_suspend"); - - //% "Mastodon" - const char *const TrIdMastodon = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-notification-mastodon"); - //% "New notification" - const char *const TrIdNewNotification = QT_TRID_NOOP("lipstick-jolla-home-la-mastodon-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 notifications = Notification::notifications(); - foreach (QObject *object, notifications) { - Notification *notification = qobject_cast(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(); - } - -} - -MastodonNotificationsSyncAdaptor::MastodonNotificationsSyncAdaptor(QObject *parent) - : MastodonNotificationsDataTypeSyncAdaptor(SocialNetworkSyncAdaptor::Notifications, parent) -{ - setInitialActive(true); -} - -MastodonNotificationsSyncAdaptor::~MastodonNotificationsSyncAdaptor() -{ -} - -QString MastodonNotificationsSyncAdaptor::syncServiceName() const -{ - return QStringLiteral("mastodon-notifications"); -} - -QString MastodonNotificationsSyncAdaptor::authServiceName() const -{ - return QStringLiteral("mastodon-microblog"); -} - -void MastodonNotificationsSyncAdaptor::purgeDataForOldAccount(int oldId, SocialNetworkSyncAdaptor::PurgeMode) -{ - closeAccountNotifications(oldId); - - m_accessTokens.remove(oldId); - m_pendingSyncStates.remove(oldId); - m_lastMarkedReadIds.remove(oldId); - saveLastFetchedId(oldId, QString()); -} - -void MastodonNotificationsSyncAdaptor::beginSync(int accountId, const QString &accessToken) -{ - m_accessTokens.insert(accountId, accessToken); - m_pendingSyncStates.remove(accountId); - requestUnreadMarker(accountId, accessToken); -} - -void MastodonNotificationsSyncAdaptor::finalize(int accountId) -{ - if (syncAborted()) { - qCInfo(lcMastodonNotifications) << "sync aborted, won't update notifications"; - } - - Q_UNUSED(accountId) -} - -QString MastodonNotificationsSyncAdaptor::sanitizeContent(const QString &content) -{ - return MastodonTextUtils::sanitizeContent(content); -} - -QDateTime MastodonNotificationsSyncAdaptor::parseTimestamp(const QString ×tampString) -{ - return MastodonTextUtils::parseTimestamp(timestampString); -} - -int MastodonNotificationsSyncAdaptor::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 MastodonNotificationsSyncAdaptor::notificationObjectKey(int accountId, const QString ¬ificationId) -{ - return QString::number(accountId) + QLatin1Char(':') + notificationId; -} - -QString MastodonNotificationsSyncAdaptor::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 MastodonNotificationsSyncAdaptor::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 MastodonNotificationsSyncAdaptor::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)), this, SLOT(sslErrorsHandler(QList))); - connect(reply, SIGNAL(finished()), this, SLOT(finishedUnreadMarkerHandler())); - - incrementSemaphore(accountId); - setupReplyTimeout(accountId, reply); - } else { - qCWarning(lcMastodonNotifications) << "unable to request notifications marker from Mastodon account with id" << accountId; - } -} - -void MastodonNotificationsSyncAdaptor::finishedUnreadMarkerHandler() -{ - QNetworkReply *reply = qobject_cast(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(lcMastodonNotifications) << "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 MastodonNotificationsSyncAdaptor::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)), this, SLOT(sslErrorsHandler(QList))); - connect(reply, SIGNAL(finished()), this, SLOT(finishedNotificationsHandler())); - - incrementSemaphore(accountId); - setupReplyTimeout(accountId, reply); - } else { - qCWarning(lcMastodonNotifications) << "unable to request notifications from Mastodon account with id" << accountId; - } -} - -void MastodonNotificationsSyncAdaptor::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)), this, SLOT(sslErrorsHandler(QList))); - connect(reply, SIGNAL(finished()), this, SLOT(finishedMarkReadHandler())); - - incrementSemaphore(accountId); - setupReplyTimeout(accountId, reply); - } else { - qCWarning(lcMastodonNotifications) << "unable to update notifications marker for Mastodon account with id" << accountId; - } -} - -void MastodonNotificationsSyncAdaptor::finishedNotificationsHandler() -{ - QNetworkReply *reply = qobject_cast(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(lcMastodonNotifications) << "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(TrIdMastodon) - : 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(lcMastodonNotifications) << "unable to parse notifications data from request with account" << accountId - << ", got:" << QString::fromUtf8(replyData); - } - - m_pendingSyncStates.remove(accountId); - decrementSemaphore(accountId); -} - -void MastodonNotificationsSyncAdaptor::finishedMarkReadHandler() -{ - QNetworkReply *reply = qobject_cast(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(lcMastodonNotifications) << "unable to update notifications marker for account" << accountId - << ", got:" << QString::fromUtf8(replyData); - } - - decrementSemaphore(accountId); -} - -void MastodonNotificationsSyncAdaptor::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(TrIdMastodon) - : 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(lcMastodonNotifications) << "failed to publish Mastodon notification" - << notificationData.notificationId; - } -} - -void MastodonNotificationsSyncAdaptor::notificationClosedWithReason(uint reason) -{ - Notification *notification = qobject_cast(sender()); - removeCachedNotification(notification); - if (reason == NotificationDismissedReason) { - markReadFromNotification(notification); - } -} - -void MastodonNotificationsSyncAdaptor::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 MastodonNotificationsSyncAdaptor::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 MastodonNotificationsSyncAdaptor::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 MastodonNotificationsSyncAdaptor::closeAccountNotifications(int accountId, - const QSet &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 notifications = Notification::notifications(); - foreach (QObject *object, notifications) { - Notification *notification = qobject_cast(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 *MastodonNotificationsSyncAdaptor::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("Mastodon")); - notification->setAppIcon(QStringLiteral("icon-l-mastodon")); - 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 *MastodonNotificationsSyncAdaptor::findNotification(int accountId, const QString ¬ificationId) -{ - Notification *notification = 0; - QList notifications = Notification::notifications(); - foreach (QObject *object, notifications) { - Notification *castedNotification = qobject_cast(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-mastodon-notifications/mastodonnotificationssyncadaptor.h b/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodonnotificationssyncadaptor.h deleted file mode 100644 index 0e9106c..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-notifications/mastodonnotificationssyncadaptor.h +++ /dev/null @@ -1,104 +0,0 @@ -/**************************************************************************** - ** - ** 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 MASTODONNOTIFICATIONSSYNCADAPTOR_H -#define MASTODONNOTIFICATIONSSYNCADAPTOR_H - -#include "mastodondatatypesyncadaptor.h" - -#include -#include -#include -#include - -class Notification; - -class MastodonNotificationsSyncAdaptor : public MastodonNotificationsDataTypeSyncAdaptor -{ - Q_OBJECT - -public: - MastodonNotificationsSyncAdaptor(QObject *parent); - ~MastodonNotificationsSyncAdaptor(); - - 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 unreadNotificationIds; - QHash pendingNotifications; - }; - - static QString sanitizeContent(const QString &content); - static QDateTime parseTimestamp(const QString ×tampString); - 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 ¬ificationData); - Notification *createNotification(int accountId, const QString ¬ificationId); - Notification *findNotification(int accountId, const QString ¬ificationId); - void closeAccountNotifications(int accountId, const QSet &keepNotificationIds = QSet()); - static QString notificationObjectKey(int accountId, const QString ¬ificationId); - 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 m_accessTokens; - QHash m_pendingSyncStates; - QHash m_lastMarkedReadIds; - QHash m_notificationObjects; -}; - -#endif // MASTODONNOTIFICATIONSSYNCADAPTOR_H diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/buteo-sync-plugin-mastodon-posts.pro b/buteo-plugins/buteo-sync-plugin-mastodon-posts/buteo-sync-plugin-mastodon-posts.pro deleted file mode 100644 index a9f65af..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-posts/buteo-sync-plugin-mastodon-posts.pro +++ /dev/null @@ -1,41 +0,0 @@ -# SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. -# -# SPDX-License-Identifier: BSD-3-Clause - -TARGET = mastodon-posts-client - -QT -= gui - -include($$PWD/../buteo-common/buteo-common.pri) -include($$PWD/../../common/common.pri) - -CONFIG += link_pkgconfig -PKGCONFIG += mlite5 nemonotifications-qt5 - -INCLUDEPATH += $$PWD - -SOURCES += \ - $$PWD/mastodondatatypesyncadaptor.cpp \ - $$PWD/mastodonpostsplugin.cpp \ - $$PWD/mastodonpostssyncadaptor.cpp - -HEADERS += \ - $$PWD/mastodondatatypesyncadaptor.h \ - $$PWD/mastodonpostsplugin.h \ - $$PWD/mastodonpostssyncadaptor.h - -OTHER_FILES += \ - $$PWD/mastodon-posts.xml \ - $$PWD/mastodon.Posts.xml - -TEMPLATE = lib -CONFIG += plugin -target.path = $$[QT_INSTALL_LIBS]/buteo-plugins-qt5/oopp - -sync.path = /etc/buteo/profiles/sync -sync.files = mastodon.Posts.xml - -client.path = /etc/buteo/profiles/client -client.files = mastodon-posts.xml - -INSTALLS += target sync client diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon-posts.xml b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon-posts.xml deleted file mode 100644 index c1e25ae..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon-posts.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon.Posts.xml b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon.Posts.xml deleted file mode 100644 index c7e2448..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodon.Posts.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.cpp b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.cpp deleted file mode 100644 index 7b47fe8..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.cpp +++ /dev/null @@ -1,232 +0,0 @@ -/**************************************************************************** - ** - ** 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 "mastodondatatypesyncadaptor.h" -#include "mastodonauthutils.h" -#include "trace.h" - -#include -#include - -// libaccounts-qt5 -#include -#include -#include -#include - -// libsignon-qt5 -#include -#include -#include - -MastodonDataTypeSyncAdaptor::MastodonDataTypeSyncAdaptor( - SocialNetworkSyncAdaptor::DataType dataType, - QObject *parent) - : SocialNetworkSyncAdaptor(QStringLiteral("mastodon"), dataType, 0, parent) -{ -} - -MastodonDataTypeSyncAdaptor::~MastodonDataTypeSyncAdaptor() -{ -} - -void MastodonDataTypeSyncAdaptor::sync(const QString &dataTypeString, int accountId) -{ - if (dataTypeString != SocialNetworkSyncAdaptor::dataTypeName(m_dataType)) { - qCWarning(lcSocialPlugin) << "Mastodon" << SocialNetworkSyncAdaptor::dataTypeName(m_dataType) - << "sync adaptor was asked to sync" << dataTypeString; - setStatus(SocialNetworkSyncAdaptor::Error); - return; - } - - setStatus(SocialNetworkSyncAdaptor::Busy); - updateDataForAccount(accountId); - qCDebug(lcSocialPlugin) << "successfully triggered sync with profile:" << m_accountSyncProfile->name(); -} - -void MastodonDataTypeSyncAdaptor::updateDataForAccount(int accountId) -{ - Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this); - if (!account) { - qCWarning(lcSocialPlugin) << "existing account with id" << accountId << "couldn't be retrieved"; - setStatus(SocialNetworkSyncAdaptor::Error); - return; - } - - incrementSemaphore(accountId); - signIn(account); -} - -QString MastodonDataTypeSyncAdaptor::apiHost(int accountId) const -{ - return m_apiHosts.value(accountId, QStringLiteral("https://mastodon.social")); -} - -void MastodonDataTypeSyncAdaptor::errorHandler(QNetworkReply::NetworkError err) -{ - QNetworkReply *reply = qobject_cast(sender()); - if (!reply) { - return; - } - - const int accountId = reply->property("accountId").toInt(); - const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - - qCWarning(lcSocialPlugin) << SocialNetworkSyncAdaptor::dataTypeName(m_dataType) - << "request with account" << accountId - << "experienced error:" << err - << "HTTP:" << httpStatus; - - reply->setProperty("isError", QVariant::fromValue(true)); - - if (httpStatus == 401 || err == QNetworkReply::AuthenticationRequiredError) { - Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this); - if (account) { - setCredentialsNeedUpdate(account); - } - } -} - -void MastodonDataTypeSyncAdaptor::sslErrorsHandler(const QList &errs) -{ - QString sslerrs; - foreach (const QSslError &e, errs) { - sslerrs += e.errorString() + QLatin1String("; "); - } - if (!sslerrs.isEmpty()) { - sslerrs.chop(2); - } - - qCWarning(lcSocialPlugin) << SocialNetworkSyncAdaptor::dataTypeName(m_dataType) - << "request with account" << sender()->property("accountId").toInt() - << "experienced ssl errors:" << sslerrs; - sender()->setProperty("isError", QVariant::fromValue(true)); -} - -void MastodonDataTypeSyncAdaptor::setCredentialsNeedUpdate(Accounts::Account *account) -{ - qCInfo(lcSocialPlugin) << "sociald:Mastodon: setting CredentialsNeedUpdate to true for account:" << account->id(); - Accounts::Service srv(m_accountManager->service(syncServiceName())); - account->selectService(srv); - account->setValue(QStringLiteral("CredentialsNeedUpdate"), QVariant::fromValue(true)); - account->setValue(QStringLiteral("CredentialsNeedUpdateFrom"), QVariant::fromValue(QString::fromLatin1("sociald-mastodon"))); - account->selectService(Accounts::Service()); - account->syncAndBlock(); -} - -void MastodonDataTypeSyncAdaptor::signIn(Accounts::Account *account) -{ - const int accountId = account->id(); - if (!checkAccount(account)) { - decrementSemaphore(accountId); - return; - } - - Accounts::Service srv(m_accountManager->service(syncServiceName())); - account->selectService(srv); - SignOn::Identity *identity = account->credentialsId() > 0 - ? SignOn::Identity::existingIdentity(account->credentialsId()) - : 0; - if (!identity) { - qCWarning(lcSocialPlugin) << "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(lcSocialPlugin) << "could not create signon session for account" << accountId; - identity->deleteLater(); - decrementSemaphore(accountId); - return; - } - - QVariantMap signonSessionData = accSrv.authData().parameters(); - MastodonAuthUtils::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(account)); - session->setProperty("identity", QVariant::fromValue(identity)); - session->process(SignOn::SessionData(signonSessionData), mechanism); -} - -void MastodonDataTypeSyncAdaptor::signOnError(const SignOn::Error &error) -{ - SignOn::AuthSession *session = qobject_cast(sender()); - Accounts::Account *account = session->property("account").value(); - SignOn::Identity *identity = session->property("identity").value(); - const int accountId = account->id(); - - qCWarning(lcSocialPlugin) << "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 MastodonDataTypeSyncAdaptor::signOnResponse(const SignOn::SessionData &responseData) -{ - const QVariantMap data = MastodonAuthUtils::responseDataToMap(responseData); - - QString accessToken; - SignOn::AuthSession *session = qobject_cast(sender()); - Accounts::Account *account = session->property("account").value(); - SignOn::Identity *identity = session->property("identity").value(); - const int accountId = account->id(); - - accessToken = MastodonAuthUtils::accessToken(data); - if (accessToken.isEmpty()) { - qCWarning(lcSocialPlugin) << "signon response for account with id" << accountId - << "contained no access token; keys:" << data.keys(); - } - - m_apiHosts.insert(accountId, MastodonAuthUtils::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-mastodon-posts/mastodondatatypesyncadaptor.h b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.h deleted file mode 100644 index 3ebbbf5..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodondatatypesyncadaptor.h +++ /dev/null @@ -1,69 +0,0 @@ -/**************************************************************************** - ** - ** 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 MASTODONDATATYPESYNCADAPTOR_H -#define MASTODONDATATYPESYNCADAPTOR_H - -#include "socialnetworksyncadaptor.h" - -#include -#include -#include - -namespace Accounts { - class Account; -} -namespace SignOn { - class Error; - class SessionData; -} - -class MastodonDataTypeSyncAdaptor : public SocialNetworkSyncAdaptor -{ - Q_OBJECT - -public: - MastodonDataTypeSyncAdaptor(SocialNetworkSyncAdaptor::DataType dataType, QObject *parent); - virtual ~MastodonDataTypeSyncAdaptor(); - - void sync(const QString &dataTypeString, int accountId) override; - -protected: - QString apiHost(int accountId) const; - virtual void updateDataForAccount(int accountId); - virtual void beginSync(int accountId, const QString &accessToken) = 0; - -protected Q_SLOTS: - virtual void errorHandler(QNetworkReply::NetworkError err); - virtual void sslErrorsHandler(const QList &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 m_apiHosts; -}; - -#endif // MASTODONDATATYPESYNCADAPTOR_H diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.cpp b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.cpp deleted file mode 100644 index a196180..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.cpp +++ /dev/null @@ -1,49 +0,0 @@ -/**************************************************************************** - ** - ** 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 "mastodonpostsplugin.h" -#include "mastodonpostssyncadaptor.h" -#include "socialnetworksyncadaptor.h" - -MastodonPostsPlugin::MastodonPostsPlugin(const QString& pluginName, - const Buteo::SyncProfile& profile, - Buteo::PluginCbInterface *callbackInterface) - : SocialdButeoPlugin(pluginName, profile, callbackInterface, - QStringLiteral("mastodon"), - SocialNetworkSyncAdaptor::dataTypeName(SocialNetworkSyncAdaptor::Posts)) -{ -} - -MastodonPostsPlugin::~MastodonPostsPlugin() -{ -} - -SocialNetworkSyncAdaptor *MastodonPostsPlugin::createSocialNetworkSyncAdaptor() -{ - return new MastodonPostsSyncAdaptor(this); -} - -Buteo::ClientPlugin* MastodonPostsPluginLoader::createClientPlugin( - const QString& pluginName, - const Buteo::SyncProfile& profile, - Buteo::PluginCbInterface* cbInterface) -{ - return new MastodonPostsPlugin(pluginName, profile, cbInterface); -} diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.h b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.h deleted file mode 100644 index c8a1d6b..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostsplugin.h +++ /dev/null @@ -1,54 +0,0 @@ -/**************************************************************************** - ** - ** 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 MASTODONPOSTSPLUGIN_H -#define MASTODONPOSTSPLUGIN_H - -#include "socialdbuteoplugin.h" - -#include - -class Q_DECL_EXPORT MastodonPostsPlugin : public SocialdButeoPlugin -{ - Q_OBJECT - -public: - MastodonPostsPlugin(const QString& pluginName, - const Buteo::SyncProfile& profile, - Buteo::PluginCbInterface *cbInterface); - ~MastodonPostsPlugin(); - -protected: - SocialNetworkSyncAdaptor *createSocialNetworkSyncAdaptor() override; -}; - -class MastodonPostsPluginLoader : public Buteo::SyncPluginLoader -{ - Q_OBJECT - Q_PLUGIN_METADATA(IID "org.sailfishos.plugins.sync.MastodonPostsPluginLoader") - Q_INTERFACES(Buteo::SyncPluginLoader) - -public: - Buteo::ClientPlugin* createClientPlugin(const QString& pluginName, - const Buteo::SyncProfile& profile, - Buteo::PluginCbInterface* cbInterface) override; -}; - -#endif // MASTODONPOSTSPLUGIN_H diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp deleted file mode 100644 index 160d6cc..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp +++ /dev/null @@ -1,257 +0,0 @@ -/**************************************************************************** - ** - ** 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 "mastodonpostssyncadaptor.h" -#include "trace.h" -#include "mastodontextutils.h" - -#include -#include -#include -#include -#include -#include - -namespace { - 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(); - } -} - -MastodonPostsSyncAdaptor::MastodonPostsSyncAdaptor(QObject *parent) - : MastodonDataTypeSyncAdaptor(SocialNetworkSyncAdaptor::Posts, parent) -{ - setInitialActive(m_db.isValid()); -} - -MastodonPostsSyncAdaptor::~MastodonPostsSyncAdaptor() -{ -} - -QString MastodonPostsSyncAdaptor::syncServiceName() const -{ - return QStringLiteral("mastodon-microblog"); -} - -void MastodonPostsSyncAdaptor::purgeDataForOldAccount(int oldId, SocialNetworkSyncAdaptor::PurgeMode) -{ - m_db.removePosts(oldId); - m_db.commit(); - m_db.wait(); - m_db.refresh(); - m_db.wait(); - - purgeCachedImages(&m_imageCacheDb, oldId); -} - -void MastodonPostsSyncAdaptor::beginSync(int accountId, const QString &accessToken) -{ - requestPosts(accountId, accessToken); -} - -void MastodonPostsSyncAdaptor::finalize(int accountId) -{ - if (syncAborted()) { - qCInfo(lcSocialPlugin) << "sync aborted, won't commit database changes"; - } else { - m_db.commit(); - m_db.wait(); - m_db.refresh(); - m_db.wait(); - purgeExpiredImages(&m_imageCacheDb, accountId); - } -} - -QString MastodonPostsSyncAdaptor::sanitizeContent(const QString &content) -{ - return MastodonTextUtils::sanitizeContent(content); -} - -QDateTime MastodonPostsSyncAdaptor::parseTimestamp(const QString ×tampString) -{ - return MastodonTextUtils::parseTimestamp(timestampString); -} - -void MastodonPostsSyncAdaptor::requestPosts(int accountId, const QString &accessToken) -{ - QUrl url(apiHost(accountId) + QStringLiteral("/api/v1/timelines/home")); - - QUrlQuery query(url); - query.addQueryItem(QStringLiteral("limit"), QStringLiteral("20")); - 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); - connect(reply, SIGNAL(error(QNetworkReply::NetworkError)), this, SLOT(errorHandler(QNetworkReply::NetworkError))); - connect(reply, SIGNAL(sslErrors(QList)), this, SLOT(sslErrorsHandler(QList))); - connect(reply, SIGNAL(finished()), this, SLOT(finishedPostsHandler())); - - incrementSemaphore(accountId); - setupReplyTimeout(accountId, reply); - } else { - qCWarning(lcSocialPlugin) << "unable to request home timeline posts from Mastodon account with id" << accountId; - } -} - -void MastodonPostsSyncAdaptor::finishedPostsHandler() -{ - QNetworkReply *reply = qobject_cast(sender()); - if (!reply) { - return; - } - - const bool isError = reply->property("isError").toBool(); - const int accountId = reply->property("accountId").toInt(); - QByteArray replyData = reply->readAll(); - - disconnect(reply); - reply->deleteLater(); - removeReplyTimeout(accountId, reply); - - bool ok = false; - QJsonArray statuses = parseJsonArrayReplyData(replyData, &ok); - if (!isError && ok) { - m_db.removePosts(accountId); - - if (!statuses.size()) { - qCDebug(lcSocialPlugin) << "no feed posts received for account" << accountId; - decrementSemaphore(accountId); - return; - } - - const int sinceSpan = m_accountSyncProfile - ? m_accountSyncProfile->key(Buteo::KEY_SYNC_SINCE_DAYS_PAST, QStringLiteral("7")).toInt() - : 7; - - foreach (const QJsonValue &statusValue, statuses) { - const QJsonObject statusObject = statusValue.toObject(); - if (statusObject.isEmpty()) { - continue; - } - - QJsonObject postObject = statusObject; - QString boostedBy; - if (statusObject.contains(QStringLiteral("reblog")) - && statusObject.value(QStringLiteral("reblog")).isObject() - && !statusObject.value(QStringLiteral("reblog")).isNull()) { - boostedBy = displayNameForAccount(statusObject.value(QStringLiteral("account")).toObject()); - postObject = statusObject.value(QStringLiteral("reblog")).toObject(); - } - - QDateTime eventTimestamp = parseTimestamp(statusObject.value(QStringLiteral("created_at")).toString()); - if (!eventTimestamp.isValid()) { - eventTimestamp = parseTimestamp(postObject.value(QStringLiteral("created_at")).toString()); - } - if (!eventTimestamp.isValid()) { - continue; - } - - if (eventTimestamp.daysTo(QDateTime::currentDateTime()) > sinceSpan) { - continue; - } - - const QJsonObject account = postObject.value(QStringLiteral("account")).toObject(); - const QString displayName = displayNameForAccount(account); - const QString accountName = account.value(QStringLiteral("acct")).toString(); - QString icon = account.value(QStringLiteral("avatar_static")).toString(); - if (icon.isEmpty()) { - icon = account.value(QStringLiteral("avatar")).toString(); - } - - QString identifier = postObject.value(QStringLiteral("id")).toVariant().toString(); - if (identifier.isEmpty()) { - continue; - } - - QString url = postObject.value(QStringLiteral("url")).toString(); - if (url.isEmpty() && !accountName.isEmpty()) { - url = QStringLiteral("%1/@%2/%3").arg(apiHost(accountId), accountName, identifier); - } - - const QString body = sanitizeContent(postObject.value(QStringLiteral("content")).toString()); - const int repliesCount = postObject.value(QStringLiteral("replies_count")).toInt(); - const int favouritesCount = postObject.value(QStringLiteral("favourites_count")).toInt(); - const int reblogsCount = postObject.value(QStringLiteral("reblogs_count")).toInt(); - const bool favourited = postObject.value(QStringLiteral("favourited")).toBool(); - const bool reblogged = postObject.value(QStringLiteral("reblogged")).toBool(); - - QList > imageList; - const QJsonArray mediaAttachments = postObject.value(QStringLiteral("media_attachments")).toArray(); - foreach (const QJsonValue &attachmentValue, mediaAttachments) { - const QJsonObject attachment = attachmentValue.toObject(); - const QString mediaType = attachment.value(QStringLiteral("type")).toString(); - - QString mediaUrl; - SocialPostImage::ImageType imageType = SocialPostImage::Invalid; - if (mediaType == QLatin1String("image")) { - mediaUrl = attachment.value(QStringLiteral("url")).toString(); - imageType = SocialPostImage::Photo; - } else if (mediaType == QLatin1String("video") || mediaType == QLatin1String("gifv")) { - mediaUrl = attachment.value(QStringLiteral("preview_url")).toString(); - if (mediaUrl.isEmpty()) { - mediaUrl = attachment.value(QStringLiteral("url")).toString(); - } - imageType = SocialPostImage::Video; - } - - if (!mediaUrl.isEmpty() && imageType != SocialPostImage::Invalid) { - imageList.append(qMakePair(mediaUrl, imageType)); - } - } - - m_db.addMastodonPost(identifier, - displayName, - accountName, - body, - eventTimestamp, - icon, - imageList, - url, - boostedBy, - repliesCount, - favouritesCount, - reblogsCount, - favourited, - reblogged, - apiHost(accountId), - accountId); - } - } else { - qCWarning(lcSocialPlugin) << "unable to parse event feed data from request with account" << accountId - << ", got:" << QString::fromUtf8(replyData); - } - - decrementSemaphore(accountId); -} diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.h b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.h deleted file mode 100644 index d2ca464..0000000 --- a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.h +++ /dev/null @@ -1,61 +0,0 @@ -/**************************************************************************** - ** - ** 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 MASTODONPOSTSSYNCADAPTOR_H -#define MASTODONPOSTSSYNCADAPTOR_H - -#include "mastodondatatypesyncadaptor.h" - -#include -#include - -#include "mastodonpostsdatabase.h" -#include - -class MastodonPostsSyncAdaptor : public MastodonDataTypeSyncAdaptor -{ - Q_OBJECT - -public: - MastodonPostsSyncAdaptor(QObject *parent); - ~MastodonPostsSyncAdaptor(); - - QString syncServiceName() const override; - -protected: - void purgeDataForOldAccount(int oldId, SocialNetworkSyncAdaptor::PurgeMode mode) override; - void beginSync(int accountId, const QString &accessToken) override; - void finalize(int accountId) override; - -private: - static QString sanitizeContent(const QString &content); - static QDateTime parseTimestamp(const QString ×tampString); - - void requestPosts(int accountId, const QString &accessToken); - -private Q_SLOTS: - void finishedPostsHandler(); - -private: - MastodonPostsDatabase m_db; - SocialImagesDatabase m_imageCacheDb; -}; - -#endif // MASTODONPOSTSSYNCADAPTOR_H diff --git a/common/common.pri b/common/common.pri index 7f593db..1e75506 100644 --- a/common/common.pri +++ b/common/common.pri @@ -5,4 +5,4 @@ INCLUDEPATH += $$PWD DEPENDPATH += . -LIBS += -L$$PWD -lmastodoncommon +LIBS += -L$$PWD -lfediversecommon diff --git a/common/common.pro b/common/common.pro index c01e571..13aadb0 100644 --- a/common/common.pro +++ b/common/common.pro @@ -10,16 +10,16 @@ QT += sql CONFIG += link_pkgconfig PKGCONFIG += socialcache -TARGET = mastodoncommon +TARGET = fediversecommon TARGET = $$qtLibraryTarget($$TARGET) HEADERS += \ - $$PWD/mastodonauthutils.h \ - $$PWD/mastodontextutils.h \ - $$PWD/mastodonpostsdatabase.h + $$PWD/fediverseauthutils.h \ + $$PWD/fediversetextutils.h \ + $$PWD/fediversepostsdatabase.h SOURCES += \ - $$PWD/mastodonpostsdatabase.cpp + $$PWD/fediversepostsdatabase.cpp TARGETPATH = $$[QT_INSTALL_LIBS] target.path = $$TARGETPATH diff --git a/common/fediverseauthutils.h b/common/fediverseauthutils.h new file mode 100644 index 0000000..01f264a --- /dev/null +++ b/common/fediverseauthutils.h @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2013-2026 Jolla Ltd. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef FEDIVERSEAUTHUTILS_H +#define FEDIVERSEAUTHUTILS_H + +#include +#include + +#include + +#include + +namespace FediverseAuthUtils { + +inline QString defaultServerHost() +{ + return QStringLiteral("mastodon.social"); +} + +inline QString defaultApiHost() +{ + return QStringLiteral("https://") + defaultServerHost(); +} + +inline QString normalizeApiHost(const QString &rawHost) +{ + QString host = rawHost.trimmed(); + if (host.isEmpty()) { + host = defaultServerHost(); + } + if (!host.startsWith(QLatin1String("https://")) + && !host.startsWith(QLatin1String("http://"))) { + host.prepend(QStringLiteral("https://")); + } + + QUrl url(host); + if (!url.isValid() || url.host().isEmpty()) { + return defaultApiHost(); + } + + QString normalized = QString::fromLatin1(url.toEncoded(QUrl::RemovePath | QUrl::RemoveQuery | QUrl::RemoveFragment)); + if (normalized.endsWith(QLatin1Char('/'))) { + normalized.chop(1); + } + return normalized; +} + +inline QString signOnHost(Accounts::Account *account) +{ + QString configuredHost = account->value(QStringLiteral("auth/oauth2/web_server/Host")).toString().trimmed(); + if (configuredHost.isEmpty()) { + configuredHost = normalizeApiHost(account->value(QStringLiteral("api/Host")).toString()); + } + + if (configuredHost.startsWith(QLatin1String("https://"))) { + configuredHost.remove(0, 8); + } else if (configuredHost.startsWith(QLatin1String("http://"))) { + configuredHost.remove(0, 7); + } + + const int separator = configuredHost.indexOf(QLatin1Char('/')); + if (separator > -1) { + configuredHost.truncate(separator); + } + while (configuredHost.endsWith(QLatin1Char('/'))) { + configuredHost.chop(1); + } + if (configuredHost.isEmpty()) { + configuredHost = defaultServerHost(); + } + + return configuredHost; +} + +inline void addSignOnSessionParameters(Accounts::Account *account, QVariantMap *sessionData) +{ + sessionData->insert(QStringLiteral("Host"), signOnHost(account)); + + const QString authPath = account->value(QStringLiteral("auth/oauth2/web_server/AuthPath")).toString().trimmed(); + if (!authPath.isEmpty()) { + sessionData->insert(QStringLiteral("AuthPath"), authPath); + } + + const QString tokenPath = account->value(QStringLiteral("auth/oauth2/web_server/TokenPath")).toString().trimmed(); + if (!tokenPath.isEmpty()) { + sessionData->insert(QStringLiteral("TokenPath"), tokenPath); + } + + const QString responseType = account->value(QStringLiteral("auth/oauth2/web_server/ResponseType")).toString().trimmed(); + if (!responseType.isEmpty()) { + sessionData->insert(QStringLiteral("ResponseType"), responseType); + } + + const QString redirectUri = account->value(QStringLiteral("auth/oauth2/web_server/RedirectUri")).toString().trimmed(); + if (!redirectUri.isEmpty()) { + sessionData->insert(QStringLiteral("RedirectUri"), redirectUri); + } + + const QVariant scopeValue = account->value(QStringLiteral("auth/oauth2/web_server/Scope")); + if (scopeValue.isValid()) { + sessionData->insert(QStringLiteral("Scope"), scopeValue); + } + + const QString clientId = account->value(QStringLiteral("auth/oauth2/web_server/ClientId")).toString().trimmed(); + if (!clientId.isEmpty()) { + sessionData->insert(QStringLiteral("ClientId"), clientId); + } + + const QString clientSecret = account->value(QStringLiteral("auth/oauth2/web_server/ClientSecret")).toString().trimmed(); + if (!clientSecret.isEmpty()) { + sessionData->insert(QStringLiteral("ClientSecret"), clientSecret); + } + + sessionData->insert(QStringLiteral("UiPolicy"), SignOn::NoUserInteractionPolicy); +} + +inline QString accessToken(const QVariantMap &sessionResponseData) +{ + QString token = sessionResponseData.value(QLatin1String("AccessToken")).toString().trimmed(); + if (token.isEmpty()) { + token = sessionResponseData.value(QLatin1String("access_token")).toString().trimmed(); + } + return token; +} + +inline QVariantMap responseDataToMap(const SignOn::SessionData &responseData) +{ + QVariantMap data; + foreach (const QString &key, responseData.propertyNames()) { + data.insert(key, responseData.getProperty(key)); + } + return data; +} + +} // namespace FediverseAuthUtils + +#endif // FEDIVERSEAUTHUTILS_H diff --git a/common/fediversepostsdatabase.cpp b/common/fediversepostsdatabase.cpp new file mode 100644 index 0000000..50bdf96 --- /dev/null +++ b/common/fediversepostsdatabase.cpp @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2013-2026 Jolla Ltd. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#include "fediversepostsdatabase.h" + +namespace { + +QVariantMap postExtra(const SocialPost::ConstPtr &post) +{ + return post ? post->extra() : QVariantMap(); +} + +QString extraString(const SocialPost::ConstPtr &post, const char *key) +{ + return postExtra(post).value(QString::fromLatin1(key)).toString(); +} + +int extraInt(const SocialPost::ConstPtr &post, const char *key, int defaultValue = 0) +{ + return postExtra(post).value(QString::fromLatin1(key), defaultValue).toInt(); +} + +bool extraBool(const SocialPost::ConstPtr &post, const char *key, bool defaultValue = false) +{ + return postExtra(post).value(QString::fromLatin1(key), defaultValue).toBool(); +} + +} + +static const char *DB_NAME = "fediverse.db"; +static const char *ACCOUNT_NAME_KEY = "account_name"; +static const char *URL_KEY = "url"; +static const char *BOOSTED_BY_KEY = "boosted_by"; +static const char *REPLIES_COUNT_KEY = "replies_count"; +static const char *FAVOURITES_COUNT_KEY = "favourites_count"; +static const char *REBLOGS_COUNT_KEY = "reblogs_count"; +static const char *FAVOURITED_KEY = "favourited"; +static const char *REBLOGGED_KEY = "reblogged"; +static const char *INSTANCE_URL_KEY = "instance_url"; +static const char *INSTANCE_ICON_PATH_KEY = "instance_icon_path"; + +FediversePostsDatabase::FediversePostsDatabase() + : AbstractSocialPostCacheDatabase(QStringLiteral("fediverse"), QLatin1String(DB_NAME)) +{ +} + +FediversePostsDatabase::~FediversePostsDatabase() +{ +} + +void FediversePostsDatabase::addFediversePost( + const QString &identifier, + const QString &name, + const QString &accountName, + const QString &body, + const QDateTime ×tamp, + const QString &icon, + const QList > &images, + const QString &url, + const QString &boostedBy, + int repliesCount, + int favouritesCount, + int reblogsCount, + bool favourited, + bool reblogged, + const QString &instanceUrl, + const QString &instanceIconPath, + int account) +{ + QVariantMap extra; + extra.insert(ACCOUNT_NAME_KEY, accountName); + extra.insert(URL_KEY, url); + extra.insert(BOOSTED_BY_KEY, boostedBy); + extra.insert(REPLIES_COUNT_KEY, repliesCount); + extra.insert(FAVOURITES_COUNT_KEY, favouritesCount); + extra.insert(REBLOGS_COUNT_KEY, reblogsCount); + extra.insert(FAVOURITED_KEY, favourited); + extra.insert(REBLOGGED_KEY, reblogged); + extra.insert(INSTANCE_URL_KEY, instanceUrl); + extra.insert(INSTANCE_ICON_PATH_KEY, instanceIconPath); + addPost(identifier, name, body, timestamp, icon, images, extra, account); +} + +QString FediversePostsDatabase::accountName(const SocialPost::ConstPtr &post) +{ + return extraString(post, ACCOUNT_NAME_KEY); +} + +QString FediversePostsDatabase::url(const SocialPost::ConstPtr &post) +{ + return extraString(post, URL_KEY); +} + +QString FediversePostsDatabase::boostedBy(const SocialPost::ConstPtr &post) +{ + return extraString(post, BOOSTED_BY_KEY); +} + +int FediversePostsDatabase::repliesCount(const SocialPost::ConstPtr &post) +{ + return extraInt(post, REPLIES_COUNT_KEY); +} + +int FediversePostsDatabase::favouritesCount(const SocialPost::ConstPtr &post) +{ + return extraInt(post, FAVOURITES_COUNT_KEY); +} + +int FediversePostsDatabase::reblogsCount(const SocialPost::ConstPtr &post) +{ + return extraInt(post, REBLOGS_COUNT_KEY); +} + +bool FediversePostsDatabase::favourited(const SocialPost::ConstPtr &post) +{ + return extraBool(post, FAVOURITED_KEY); +} + +bool FediversePostsDatabase::reblogged(const SocialPost::ConstPtr &post) +{ + return extraBool(post, REBLOGGED_KEY); +} + +QString FediversePostsDatabase::instanceUrl(const SocialPost::ConstPtr &post) +{ + return extraString(post, INSTANCE_URL_KEY); +} + +QString FediversePostsDatabase::instanceIconPath(const SocialPost::ConstPtr &post) +{ + return extraString(post, INSTANCE_ICON_PATH_KEY); +} diff --git a/common/fediversepostsdatabase.h b/common/fediversepostsdatabase.h new file mode 100644 index 0000000..2c085e0 --- /dev/null +++ b/common/fediversepostsdatabase.h @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2013-2026 Jolla Ltd. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef FEDIVERSEPOSTSDATABASE_H +#define FEDIVERSEPOSTSDATABASE_H + +#include + +class FediversePostsDatabase: public AbstractSocialPostCacheDatabase +{ + Q_OBJECT +public: + FediversePostsDatabase(); + ~FediversePostsDatabase(); + + void addFediversePost(const QString &identifier, const QString &name, + const QString &accountName, const QString &body, + const QDateTime ×tamp, + const QString &icon, + const QList > &images, + const QString &url, const QString &boostedBy, + int repliesCount, int favouritesCount, int reblogsCount, + bool favourited, bool reblogged, + const QString &instanceUrl, + const QString &instanceIconPath, + int account); + + static QString accountName(const SocialPost::ConstPtr &post); + static QString url(const SocialPost::ConstPtr &post); + static QString boostedBy(const SocialPost::ConstPtr &post); + static int repliesCount(const SocialPost::ConstPtr &post); + static int favouritesCount(const SocialPost::ConstPtr &post); + static int reblogsCount(const SocialPost::ConstPtr &post); + static bool favourited(const SocialPost::ConstPtr &post); + static bool reblogged(const SocialPost::ConstPtr &post); + static QString instanceUrl(const SocialPost::ConstPtr &post); + static QString instanceIconPath(const SocialPost::ConstPtr &post); +}; + +#endif // FEDIVERSEPOSTSDATABASE_H diff --git a/common/fediversetextutils.h b/common/fediversetextutils.h new file mode 100644 index 0000000..4fa7aed --- /dev/null +++ b/common/fediversetextutils.h @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2013-2026 Jolla Ltd. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef FEDIVERSETEXTUTILS_H +#define FEDIVERSETEXTUTILS_H + +#include +#include +#include + +namespace FediverseTextUtils { + +inline QString decodeHtmlEntities(QString text) +{ + text.replace(QStringLiteral("""), QStringLiteral("\"")); + text.replace(QStringLiteral("'"), QStringLiteral("'")); + text.replace(QStringLiteral("<"), QStringLiteral("<")); + text.replace(QStringLiteral(">"), QStringLiteral(">")); + text.replace(QStringLiteral("&"), QStringLiteral("&")); + text.replace(QStringLiteral(" "), QStringLiteral(" ")); + + static const QRegularExpression decimalEntity(QStringLiteral("&#(\\d+);")); + QRegularExpressionMatch match; + int index = 0; + while ((index = text.indexOf(decimalEntity, index, &match)) != -1) { + bool ok = false; + const uint value = match.captured(1).toUInt(&ok, 10); + QString replacement; + if (ok && value > 0 && value <= 0x10FFFF) { + replacement = QString::fromUcs4(&value, 1); + } + text.replace(index, match.capturedLength(0), replacement); + index += replacement.size(); + } + + static const QRegularExpression hexEntity(QStringLiteral("&#x([0-9a-fA-F]+);")); + index = 0; + while ((index = text.indexOf(hexEntity, index, &match)) != -1) { + bool ok = false; + const uint value = match.captured(1).toUInt(&ok, 16); + QString replacement; + if (ok && value > 0 && value <= 0x10FFFF) { + replacement = QString::fromUcs4(&value, 1); + } + text.replace(index, match.capturedLength(0), replacement); + index += replacement.size(); + } + + return text; +} + +inline QString sanitizeContent(const QString &content) +{ + QString plain = content; + plain.replace(QRegularExpression(QStringLiteral("<\\s*br\\s*/?\\s*>"), QRegularExpression::CaseInsensitiveOption), + QStringLiteral("\n")); + plain.replace(QRegularExpression(QStringLiteral("<\\s*/\\s*p\\s*>"), QRegularExpression::CaseInsensitiveOption), + QStringLiteral("\n")); + plain.remove(QRegularExpression(QStringLiteral("<[^>]+>"), QRegularExpression::CaseInsensitiveOption)); + + return decodeHtmlEntities(plain).trimmed(); +} + +inline QDateTime parseTimestamp(const QString ×tampString) +{ + QDateTime timestamp; + +#if QT_VERSION >= QT_VERSION_CHECK(5, 8, 0) + timestamp = QDateTime::fromString(timestampString, Qt::ISODateWithMs); + if (timestamp.isValid()) { + return timestamp; + } +#endif + + timestamp = QDateTime::fromString(timestampString, Qt::ISODate); + if (timestamp.isValid()) { + return timestamp; + } + + // Qt 5.6 cannot parse ISO-8601 timestamps with fractional seconds. + const int timeSeparator = timestampString.indexOf(QLatin1Char('T')); + const int fractionSeparator = timestampString.indexOf(QLatin1Char('.'), timeSeparator + 1); + if (timeSeparator > -1 && fractionSeparator > -1) { + int timezoneSeparator = timestampString.indexOf(QLatin1Char('Z'), fractionSeparator + 1); + if (timezoneSeparator == -1) { + timezoneSeparator = timestampString.indexOf(QLatin1Char('+'), fractionSeparator + 1); + } + if (timezoneSeparator == -1) { + timezoneSeparator = timestampString.indexOf(QLatin1Char('-'), fractionSeparator + 1); + } + + QString stripped = timestampString; + if (timezoneSeparator > -1) { + stripped.remove(fractionSeparator, timezoneSeparator - fractionSeparator); + } else { + stripped.truncate(fractionSeparator); + } + + timestamp = QDateTime::fromString(stripped, Qt::ISODate); + } + + return timestamp; +} + +} // namespace FediverseTextUtils + +#endif // FEDIVERSETEXTUTILS_H diff --git a/common/mastodonauthutils.h b/common/mastodonauthutils.h deleted file mode 100644 index 3f1fc85..0000000 --- a/common/mastodonauthutils.h +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright (C) 2013-2026 Jolla Ltd. - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This 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 library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -#ifndef MASTODONAUTHUTILS_H -#define MASTODONAUTHUTILS_H - -#include -#include - -#include - -#include - -namespace MastodonAuthUtils { - -inline QString normalizeApiHost(const QString &rawHost) -{ - QString host = rawHost.trimmed(); - if (host.isEmpty()) { - host = QStringLiteral("https://mastodon.social"); - } - if (!host.startsWith(QLatin1String("https://")) - && !host.startsWith(QLatin1String("http://"))) { - host.prepend(QStringLiteral("https://")); - } - - QUrl url(host); - if (!url.isValid() || url.host().isEmpty()) { - return QStringLiteral("https://mastodon.social"); - } - - QString normalized = QString::fromLatin1(url.toEncoded(QUrl::RemovePath | QUrl::RemoveQuery | QUrl::RemoveFragment)); - if (normalized.endsWith(QLatin1Char('/'))) { - normalized.chop(1); - } - return normalized; -} - -inline QString signOnHost(Accounts::Account *account) -{ - QString configuredHost = account->value(QStringLiteral("auth/oauth2/web_server/Host")).toString().trimmed(); - if (configuredHost.isEmpty()) { - configuredHost = normalizeApiHost(account->value(QStringLiteral("api/Host")).toString()); - } - - if (configuredHost.startsWith(QLatin1String("https://"))) { - configuredHost.remove(0, 8); - } else if (configuredHost.startsWith(QLatin1String("http://"))) { - configuredHost.remove(0, 7); - } - - const int separator = configuredHost.indexOf(QLatin1Char('/')); - if (separator > -1) { - configuredHost.truncate(separator); - } - while (configuredHost.endsWith(QLatin1Char('/'))) { - configuredHost.chop(1); - } - if (configuredHost.isEmpty()) { - configuredHost = QStringLiteral("mastodon.social"); - } - - return configuredHost; -} - -inline void addSignOnSessionParameters(Accounts::Account *account, QVariantMap *sessionData) -{ - sessionData->insert(QStringLiteral("Host"), signOnHost(account)); - - const QString authPath = account->value(QStringLiteral("auth/oauth2/web_server/AuthPath")).toString().trimmed(); - if (!authPath.isEmpty()) { - sessionData->insert(QStringLiteral("AuthPath"), authPath); - } - - const QString tokenPath = account->value(QStringLiteral("auth/oauth2/web_server/TokenPath")).toString().trimmed(); - if (!tokenPath.isEmpty()) { - sessionData->insert(QStringLiteral("TokenPath"), tokenPath); - } - - const QString responseType = account->value(QStringLiteral("auth/oauth2/web_server/ResponseType")).toString().trimmed(); - if (!responseType.isEmpty()) { - sessionData->insert(QStringLiteral("ResponseType"), responseType); - } - - const QString redirectUri = account->value(QStringLiteral("auth/oauth2/web_server/RedirectUri")).toString().trimmed(); - if (!redirectUri.isEmpty()) { - sessionData->insert(QStringLiteral("RedirectUri"), redirectUri); - } - - const QVariant scopeValue = account->value(QStringLiteral("auth/oauth2/web_server/Scope")); - if (scopeValue.isValid()) { - sessionData->insert(QStringLiteral("Scope"), scopeValue); - } - - const QString clientId = account->value(QStringLiteral("auth/oauth2/web_server/ClientId")).toString().trimmed(); - if (!clientId.isEmpty()) { - sessionData->insert(QStringLiteral("ClientId"), clientId); - } - - const QString clientSecret = account->value(QStringLiteral("auth/oauth2/web_server/ClientSecret")).toString().trimmed(); - if (!clientSecret.isEmpty()) { - sessionData->insert(QStringLiteral("ClientSecret"), clientSecret); - } - - sessionData->insert(QStringLiteral("UiPolicy"), SignOn::NoUserInteractionPolicy); -} - -inline QString accessToken(const QVariantMap &sessionResponseData) -{ - QString token = sessionResponseData.value(QLatin1String("AccessToken")).toString().trimmed(); - if (token.isEmpty()) { - token = sessionResponseData.value(QLatin1String("access_token")).toString().trimmed(); - } - return token; -} - -inline QVariantMap responseDataToMap(const SignOn::SessionData &responseData) -{ - QVariantMap data; - foreach (const QString &key, responseData.propertyNames()) { - data.insert(key, responseData.getProperty(key)); - } - return data; -} - -} // namespace MastodonAuthUtils - -#endif // MASTODONAUTHUTILS_H diff --git a/common/mastodonpostsdatabase.cpp b/common/mastodonpostsdatabase.cpp deleted file mode 100644 index 7f82162..0000000 --- a/common/mastodonpostsdatabase.cpp +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright (C) 2013-2026 Jolla Ltd. - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This 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 library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -#include "mastodonpostsdatabase.h" - -static const char *DB_NAME = "mastodon.db"; -static const char *ACCOUNT_NAME_KEY = "account_name"; -static const char *URL_KEY = "url"; -static const char *BOOSTED_BY_KEY = "boosted_by"; -static const char *REPLIES_COUNT_KEY = "replies_count"; -static const char *FAVOURITES_COUNT_KEY = "favourites_count"; -static const char *REBLOGS_COUNT_KEY = "reblogs_count"; -static const char *FAVOURITED_KEY = "favourited"; -static const char *REBLOGGED_KEY = "reblogged"; -static const char *INSTANCE_URL_KEY = "instance_url"; - -MastodonPostsDatabase::MastodonPostsDatabase() - : AbstractSocialPostCacheDatabase(QStringLiteral("mastodon"), QLatin1String(DB_NAME)) -{ -} - -MastodonPostsDatabase::~MastodonPostsDatabase() -{ -} - -void MastodonPostsDatabase::addMastodonPost( - const QString &identifier, - const QString &name, - const QString &accountName, - const QString &body, - const QDateTime ×tamp, - const QString &icon, - const QList > &images, - const QString &url, - const QString &boostedBy, - int repliesCount, - int favouritesCount, - int reblogsCount, - bool favourited, - bool reblogged, - const QString &instanceUrl, - int account) -{ - QVariantMap extra; - extra.insert(ACCOUNT_NAME_KEY, accountName); - extra.insert(URL_KEY, url); - extra.insert(BOOSTED_BY_KEY, boostedBy); - extra.insert(REPLIES_COUNT_KEY, repliesCount); - extra.insert(FAVOURITES_COUNT_KEY, favouritesCount); - extra.insert(REBLOGS_COUNT_KEY, reblogsCount); - extra.insert(FAVOURITED_KEY, favourited); - extra.insert(REBLOGGED_KEY, reblogged); - extra.insert(INSTANCE_URL_KEY, instanceUrl); - addPost(identifier, name, body, timestamp, icon, images, extra, account); -} - -QString MastodonPostsDatabase::accountName(const SocialPost::ConstPtr &post) -{ - if (post.isNull()) { - return QString(); - } - return post->extra().value(ACCOUNT_NAME_KEY).toString(); -} - -QString MastodonPostsDatabase::url(const SocialPost::ConstPtr &post) -{ - if (post.isNull()) { - return QString(); - } - return post->extra().value(URL_KEY).toString(); -} - -QString MastodonPostsDatabase::boostedBy(const SocialPost::ConstPtr &post) -{ - if (post.isNull()) { - return QString(); - } - return post->extra().value(BOOSTED_BY_KEY).toString(); -} - -int MastodonPostsDatabase::repliesCount(const SocialPost::ConstPtr &post) -{ - if (post.isNull()) { - return 0; - } - return post->extra().value(REPLIES_COUNT_KEY).toInt(); -} - -int MastodonPostsDatabase::favouritesCount(const SocialPost::ConstPtr &post) -{ - if (post.isNull()) { - return 0; - } - return post->extra().value(FAVOURITES_COUNT_KEY).toInt(); -} - -int MastodonPostsDatabase::reblogsCount(const SocialPost::ConstPtr &post) -{ - if (post.isNull()) { - return 0; - } - return post->extra().value(REBLOGS_COUNT_KEY).toInt(); -} - -bool MastodonPostsDatabase::favourited(const SocialPost::ConstPtr &post) -{ - if (post.isNull()) { - return false; - } - return post->extra().value(FAVOURITED_KEY).toBool(); -} - -bool MastodonPostsDatabase::reblogged(const SocialPost::ConstPtr &post) -{ - if (post.isNull()) { - return false; - } - return post->extra().value(REBLOGGED_KEY).toBool(); -} - -QString MastodonPostsDatabase::instanceUrl(const SocialPost::ConstPtr &post) -{ - if (post.isNull()) { - return QString(); - } - return post->extra().value(INSTANCE_URL_KEY).toString(); -} diff --git a/common/mastodonpostsdatabase.h b/common/mastodonpostsdatabase.h deleted file mode 100644 index 9736fa8..0000000 --- a/common/mastodonpostsdatabase.h +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (C) 2013-2026 Jolla Ltd. - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This 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 library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -#ifndef MASTODONPOSTSDATABASE_H -#define MASTODONPOSTSDATABASE_H - -#include - -class MastodonPostsDatabase: public AbstractSocialPostCacheDatabase -{ - Q_OBJECT -public: - MastodonPostsDatabase(); - ~MastodonPostsDatabase(); - - void addMastodonPost(const QString &identifier, const QString &name, - const QString &accountName, const QString &body, - const QDateTime ×tamp, - const QString &icon, - const QList > &images, - const QString &url, const QString &boostedBy, - int repliesCount, int favouritesCount, int reblogsCount, - bool favourited, bool reblogged, - const QString &instanceUrl, - int account); - - static QString accountName(const SocialPost::ConstPtr &post); - static QString url(const SocialPost::ConstPtr &post); - static QString boostedBy(const SocialPost::ConstPtr &post); - static int repliesCount(const SocialPost::ConstPtr &post); - static int favouritesCount(const SocialPost::ConstPtr &post); - static int reblogsCount(const SocialPost::ConstPtr &post); - static bool favourited(const SocialPost::ConstPtr &post); - static bool reblogged(const SocialPost::ConstPtr &post); - static QString instanceUrl(const SocialPost::ConstPtr &post); -}; - -#endif // MASTODONPOSTSDATABASE_H diff --git a/common/mastodontextutils.h b/common/mastodontextutils.h deleted file mode 100644 index bde74c4..0000000 --- a/common/mastodontextutils.h +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright (C) 2013-2026 Jolla Ltd. - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This 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 library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -#ifndef MASTODONTEXTUTILS_H -#define MASTODONTEXTUTILS_H - -#include -#include -#include - -namespace MastodonTextUtils { - -inline QString decodeHtmlEntities(QString text) -{ - text.replace(QStringLiteral("""), QStringLiteral("\"")); - text.replace(QStringLiteral("'"), QStringLiteral("'")); - text.replace(QStringLiteral("<"), QStringLiteral("<")); - text.replace(QStringLiteral(">"), QStringLiteral(">")); - text.replace(QStringLiteral("&"), QStringLiteral("&")); - text.replace(QStringLiteral(" "), QStringLiteral(" ")); - - static const QRegularExpression decimalEntity(QStringLiteral("&#(\\d+);")); - QRegularExpressionMatch match; - int index = 0; - while ((index = text.indexOf(decimalEntity, index, &match)) != -1) { - bool ok = false; - const uint value = match.captured(1).toUInt(&ok, 10); - QString replacement; - if (ok && value > 0 && value <= 0x10FFFF) { - replacement = QString::fromUcs4(&value, 1); - } - text.replace(index, match.capturedLength(0), replacement); - index += replacement.size(); - } - - static const QRegularExpression hexEntity(QStringLiteral("&#x([0-9a-fA-F]+);")); - index = 0; - while ((index = text.indexOf(hexEntity, index, &match)) != -1) { - bool ok = false; - const uint value = match.captured(1).toUInt(&ok, 16); - QString replacement; - if (ok && value > 0 && value <= 0x10FFFF) { - replacement = QString::fromUcs4(&value, 1); - } - text.replace(index, match.capturedLength(0), replacement); - index += replacement.size(); - } - - return text; -} - -inline QString sanitizeContent(const QString &content) -{ - QString plain = content; - plain.replace(QRegularExpression(QStringLiteral("<\\s*br\\s*/?\\s*>"), QRegularExpression::CaseInsensitiveOption), - QStringLiteral("\n")); - plain.replace(QRegularExpression(QStringLiteral("<\\s*/\\s*p\\s*>"), QRegularExpression::CaseInsensitiveOption), - QStringLiteral("\n")); - plain.remove(QRegularExpression(QStringLiteral("<[^>]+>"), QRegularExpression::CaseInsensitiveOption)); - - return decodeHtmlEntities(plain).trimmed(); -} - -inline QDateTime parseTimestamp(const QString ×tampString) -{ - QDateTime timestamp; - -#if QT_VERSION >= QT_VERSION_CHECK(5, 8, 0) - timestamp = QDateTime::fromString(timestampString, Qt::ISODateWithMs); - if (timestamp.isValid()) { - return timestamp; - } -#endif - - timestamp = QDateTime::fromString(timestampString, Qt::ISODate); - if (timestamp.isValid()) { - return timestamp; - } - - // Qt 5.6 cannot parse ISO-8601 timestamps with fractional seconds. - const int timeSeparator = timestampString.indexOf(QLatin1Char('T')); - const int fractionSeparator = timestampString.indexOf(QLatin1Char('.'), timeSeparator + 1); - if (timeSeparator > -1 && fractionSeparator > -1) { - int timezoneSeparator = timestampString.indexOf(QLatin1Char('Z'), fractionSeparator + 1); - if (timezoneSeparator == -1) { - timezoneSeparator = timestampString.indexOf(QLatin1Char('+'), fractionSeparator + 1); - } - if (timezoneSeparator == -1) { - timezoneSeparator = timestampString.indexOf(QLatin1Char('-'), fractionSeparator + 1); - } - - QString stripped = timestampString; - if (timezoneSeparator > -1) { - stripped.remove(fractionSeparator, timezoneSeparator - fractionSeparator); - } else { - stripped.truncate(fractionSeparator); - } - - timestamp = QDateTime::fromString(stripped, Qt::ISODate); - } - - return timestamp; -} - -} // namespace MastodonTextUtils - -#endif // MASTODONTEXTUTILS_H diff --git a/eventsview-plugins/eventsview-plugin-fediverse/FediverseFeedItem.qml b/eventsview-plugins/eventsview-plugin-fediverse/FediverseFeedItem.qml new file mode 100644 index 0000000..16dc191 --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-fediverse/FediverseFeedItem.qml @@ -0,0 +1,362 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import Sailfish.Share 1.0 +import Sailfish.TextLinking 1.0 +import org.nemomobile.lipstick 0.1 +import "shared" + +SocialMediaFeedItem { + id: item + + property variant imageList + property string resolvedStatusUrl: model && model.url ? model.url.toString() : "" + property string postId + property QtObject postActions + property int likeCount: model && model.favouritesCount ? model.favouritesCount : 0 + property int commentCount: model && model.repliesCount ? model.repliesCount : 0 + property int boostCount: model && model.reblogsCount ? model.reblogsCount : 0 + property bool favourited: model ? !!model.favourited : false + property bool reblogged: model ? !!model.reblogged : false + property int _likeCountOverride: -1 + property int _boostCountOverride: -1 + property int _favouritedOverride: -1 + property int _rebloggedOverride: -1 + property bool isFavourited: _favouritedOverride >= 0 ? _favouritedOverride === 1 : favourited + property bool isReblogged: _rebloggedOverride >= 0 ? _rebloggedOverride === 1 : reblogged + readonly property bool housekeeping: Lipstick.compositor.eventsLayer.housekeeping + readonly property bool lockScreenActive: Lipstick.compositor.lockScreenLayer.deviceIsLocked + property bool _pendingOpenActionMenu: false + property bool _contextMenuOpen: false + property var _actionMenu + property real _contextMenuHeight: (_contextMenuOpen && _actionMenu) ? _actionMenu.height : 0 + + property string _booster: model && model.boostedBy ? model.boostedBy.toString() : "" + property string _displayName: model && model.name ? model.name.toString() : "" + property string _accountName: model && model.accountName ? model.accountName.toString() : "" + property string _bodyText: model && model.body ? model.body.toString() : "" + //: Action label shown in Fediverse interaction menu. + //% "Favourite" + readonly property string _favouriteActionText: qsTrId("lipstick-jolla-home-la-fediverse_favourite") + //: Action label shown in Fediverse interaction menu when the post is already favourited. + //% "Unfavourite" + readonly property string _unfavouriteActionText: qsTrId("lipstick-jolla-home-la-fediverse_unfavourite") + //: Action label shown in Fediverse interaction menu. + //% "Boost" + readonly property string _boostActionText: qsTrId("lipstick-jolla-home-la-fediverse_boost") + //: Action label shown in Fediverse interaction menu when the post is already boosted. + //% "Undo boost" + readonly property string _unboostActionText: qsTrId("lipstick-jolla-home-la-fediverse_unboost") + //: Action label shown in Fediverse interaction menu. + //% "Share" + readonly property string _shareActionText: qsTrId("lipstick-jolla-home-la-fediverse_share") + //: Link title used when sharing a Fediverse post. + //% "Post from Fediverse" + readonly property string _shareLinkTitle: qsTrId("lipstick-jolla-home-la-fediverse_share_link_title") + property var _shareAction: ShareAction { + title: item._shareActionText + } + + timestamp: model.timestamp + onRefreshTimeCountChanged: formattedTime = Format.formatDate(model.timestamp, Format.TimeElapsed) + onLockScreenActiveChanged: { + if (lockScreenActive && _actionMenu) { + _actionMenu.close() + } + } + onPressAndHold: function(mouse) { + if (mouse) { + mouse.accepted = true + } + _pendingOpenActionMenu = !lockScreenActive + && postActions + && actionPostId().length > 0 + && actionAccountId() >= 0 + openActionMenuTimer.restart() + } + onHousekeepingChanged: { + if (housekeeping && _pendingOpenActionMenu) { + Lipstick.compositor.eventsLayer.setHousekeeping(false) + } + } + Component.onDestruction: { + if (_actionMenu) { + _actionMenu.destroy() + _actionMenu = null + } + } + + avatar.y: item._booster.length > 0 + ? topMargin + boosterIcon.height + Theme.paddingSmall + : topMargin + contentHeight: Math.max(content.y + content.height, avatar.y + avatar.height) + bottomMargin + _contextMenuHeight + topMargin: item._booster.length > 0 ? Theme.paddingMedium : Theme.paddingLarge + userRemovable: false + + SocialReshareIcon { + id: boosterIcon + + anchors { + right: avatar.right + top: parent.top + topMargin: item.topMargin + } + visible: item._booster.length > 0 + highlighted: item.highlighted + iconSource: "image://theme/icon-s-repost" + } + + SocialReshareText { + anchors { + left: content.left + right: content.right + verticalCenter: boosterIcon.verticalCenter + } + highlighted: item.highlighted + text: item._booster.length > 0 + ? //: Shown above a post that is boosted by another user. %1 = name of user who boosted + //% "%1 boosted" + qsTrId("lipstick-jolla-home-la-boosted_by").arg(item._booster) + : "" + } + + Column { + id: content + + anchors { + left: avatar.right + leftMargin: Theme.paddingMedium + top: avatar.top + } + width: parent.width - x + + Label { + width: parent.width + truncationMode: TruncationMode.Fade + text: item._displayName + color: item.highlighted ? Theme.highlightColor : Theme.primaryColor + textFormat: Text.PlainText + } + + Label { + width: parent.width + truncationMode: TruncationMode.Fade + text: item._accountName.length > 0 && item._accountName.charAt(0) !== "@" + ? "@" + item._accountName + : item._accountName + font.pixelSize: Theme.fontSizeSmall + color: item.highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor + textFormat: Text.PlainText + } + + LinkedText { + width: parent.width + elide: Text.ElideRight + wrapMode: Text.Wrap + font.pixelSize: Theme.fontSizeSmall + shortenUrl: true + color: item.highlighted ? Theme.highlightColor : Theme.primaryColor + linkColor: Theme.highlightColor + plainText: item._bodyText + } + + SocialPostMetadataRow { + id: metadataRow + + width: parent.width + highlighted: item.highlighted + commentCount: item.commentCount + likeCount: item._likeCountOverride >= 0 ? item._likeCountOverride : item.likeCount + repostCount: item._boostCountOverride >= 0 ? item._boostCountOverride : item.boostCount + liked: item.isFavourited + reposted: item.isReblogged + timeText: item.formattedTime + addBottomPadding: previewRow.visible + } + + SocialMediaPreviewRow { + id: previewRow + + width: parent.width + Theme.horizontalPageMargin // extend to right edge of notification area + imageList: item.imageList + downloader: item.downloader + accountId: item.accountId + connectedToNetwork: item.connectedToNetwork + highlighted: item.highlighted + eventsColumnMaxWidth: item.eventsColumnMaxWidth - item.avatar.width + } + } + + function actionPostId() { + if (item.postId.length > 0) { + return item.postId + } + return model && model.fediverseId ? model.fediverseId.toString() : "" + } + + function actionAccountId() { + var parsed = Number(item.accountId) + return isNaN(parsed) ? -1 : parsed + } + + function shareStatusUrl() { + return model && model.url ? model.url.toString() : "" + } + + function topLevelParent() { + var p = item + while (p && p.parent) { + p = p.parent + } + return p + } + + function openActionMenu() { + if (_actionMenu) { + _actionMenu.destroy() + _actionMenu = null + } + + var parentItem = topLevelParent() + _actionMenu = actionMenuComponent.createObject(parentItem) + if (_actionMenu) { + _actionMenu.open(item) + } + } + + Connections { + target: item.postActions ? item.postActions : null + + onActionSucceeded: { + if (accountId !== item.actionAccountId() || statusId !== item.actionPostId()) { + return + } + + if (favouritesCount >= 0) { + item._likeCountOverride = favouritesCount + } + if (reblogsCount >= 0) { + item._boostCountOverride = reblogsCount + } + item._favouritedOverride = favourited ? 1 : 0 + item._rebloggedOverride = reblogged ? 1 : 0 + item._contextMenuOpen = false + + if (item._accountDelegate) { + item._accountDelegate.sync() + } + } + + onActionFailed: { + if (accountId !== item.actionAccountId() || statusId !== item.actionPostId()) { + return + } + console.warn("Fediverse action failed:", action, errorMessage) + item._contextMenuOpen = false + } + } + + Component { + id: actionMenuComponent + + SocialInteractionContextMenu { + id: actionMenu + z: 10000 + mapSourceItem: _contentColumn + actionEnabled: item.postActions + && item.actionPostId().length > 0 + && item.actionAccountId() >= 0 + && !item.lockScreenActive + && !item.housekeeping + interactionItems: [ + { + name: "like", + // U+2605 BLACK STAR + symbol: "\u2605", + active: item.isFavourited, + inactiveText: item._favouriteActionText, + activeText: item._unfavouriteActionText + }, + { + name: "reblog", + // U+21BB CLOCKWISE OPEN CIRCLE ARROW + symbol: "\u21BB", + active: item.isReblogged, + inactiveText: item._boostActionText, + activeText: item._unboostActionText + }, + { + name: "share", + // U+260D OPPOSITION (ironic doncha think) + symbol: "\u260D", + active: false, + inactiveText: item._shareActionText, + activeText: item._shareActionText + } + ] + + onInteractionMenuOpened: item._contextMenuOpen = true + onInteractionMenuClosed: { + item._contextMenuOpen = false + destroy() + item._actionMenu = null + } + + onInteractionTriggered: function(actionName) { + if (!actionEnabled) { + return + } + var postId = item.actionPostId() + var accountId = item.actionAccountId() + if (actionName === "like") { + if (item.isFavourited) { + item.postActions.unfavourite(accountId, postId) + } else { + item.postActions.favourite(accountId, postId) + } + } else if (actionName === "reblog") { + if (item.isReblogged) { + item.postActions.unboost(accountId, postId) + } else { + item.postActions.boost(accountId, postId) + } + } else if (actionName === "share") { + var shareUrl = item.shareStatusUrl() + if (shareUrl.length === 0) { + return + } + item._shareAction.resources = [{ + "data": shareUrl, + "linkTitle": item._shareLinkTitle, + "type": "text/x-url" + }] + item._shareAction.trigger() + } + } + } + } + + Timer { + id: openActionMenuTimer + + interval: 0 + repeat: false + onTriggered: { + if (item.lockScreenActive) { + item._pendingOpenActionMenu = false + return + } + Lipstick.compositor.eventsLayer.setHousekeeping(false) + if (item._pendingOpenActionMenu) { + item._contextMenuOpen = false + item.openActionMenu() + } + item._pendingOpenActionMenu = false + } + } +} diff --git a/eventsview-plugins/eventsview-plugin-fediverse/eventsview-plugin-fediverse.pro b/eventsview-plugins/eventsview-plugin-fediverse/eventsview-plugin-fediverse.pro new file mode 100644 index 0000000..e546e9d --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-fediverse/eventsview-plugin-fediverse.pro @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. +# +# SPDX-License-Identifier: BSD-3-Clause + +TEMPLATE = lib +TARGET = jollaeventsviewfediverseplugin +TARGET = $$qtLibraryTarget($$TARGET) + +MODULENAME = com/jolla/eventsview/fediverse +TARGETPATH = $$[QT_INSTALL_QML]/$$MODULENAME + +QT -= gui +QT += qml network +CONFIG += plugin link_pkgconfig +PKGCONFIG += socialcache accounts-qt5 libsignon-qt5 sailfishaccounts + +include($$PWD/../../common/common.pri) + +TS_FILE = $$OUT_PWD/lipstick-jolla-home-fediverse.ts +EE_QM = $$OUT_PWD/lipstick-jolla-home-fediverse_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 + +# should add -markuntranslated "-" when proper translations are in place (or for testing) +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 + +INSTALLS += ts_install engineering_english_install + +HEADERS += \ + fediversepostactions.h \ + fediversepostsmodel.h + +SOURCES += \ + fediversepostactions.cpp \ + fediversepostsmodel.cpp \ + plugin.cpp + +qml.files = fediverse-delegate.qml FediverseFeedItem.qml +qml.path = /usr/share/lipstick/eventfeed/ + +import.files = qmldir +import.path = $$TARGETPATH +target.path = $$TARGETPATH + +OTHER_FILES += $$qml.files $$import.files + +INSTALLS += target import qml diff --git a/eventsview-plugins/eventsview-plugin-fediverse/fediverse-delegate.qml b/eventsview-plugins/eventsview-plugin-fediverse/fediverse-delegate.qml new file mode 100644 index 0000000..f954db4 --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-fediverse/fediverse-delegate.qml @@ -0,0 +1,185 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import org.nemomobile.socialcache 1.0 +import com.jolla.eventsview.fediverse 1.0 +import QtQml.Models 2.1 +import "shared" + +SocialMediaAccountDelegate { + id: delegateItem + property string instanceHomeUrl: "" + property string instanceIconPath: "" + + //: Fediverse posts + //% "Posts" + headerText: qsTrId("lipstick-jolla-home-la-fediverse_posts") + headerIcon: delegateItem.instanceIconPath.length > 0 ? delegateItem.instanceIconPath : "image://theme/icon-l-fediverse" + showRemainingCount: false + + services: ["Posts"] + socialNetwork: SocialSync.Fediverse + dataType: SocialSync.Posts + providerName: "fediverse" + periodicSyncLoopEnabled: true + + FediversePostActions { + id: fediversePostActions + } + + model: FediversePostsModel {} + + delegate: FediverseFeedItem { + downloader: delegateItem.downloader + imageList: model.images + avatarSource: model.icon + fallbackAvatarSource: model.icon + resolvedStatusUrl: delegateItem.authorizeInteractionUrl(model) + postId: model.fediverseId + postActions: fediversePostActions + accountId: delegateItem.firstAccountId(model, -1) + + onTriggered: { + if (resolvedStatusUrl.length > 0) { + Qt.openUrlExternally(resolvedStatusUrl) + } + } + + Component.onCompleted: { + delegateItem.instanceHomeUrl = statusUrl({instanceUrl: model.instanceUrl}) + if (model.instanceIconPath && model.instanceIconPath.length > 0) { + delegateItem.instanceIconPath = model.instanceIconPath + } + refreshTimeCount = Qt.binding(function() { return delegateItem.refreshTimeCount }) + connectedToNetwork = Qt.binding(function() { return delegateItem.connectedToNetwork }) + eventsColumnMaxWidth = Qt.binding(function() { return delegateItem.eventsColumnMaxWidth }) + } + } + //% "Show more in Fediverse" + expandedLabel: qsTrId("lipstick-jolla-home-la-show-more-in-fediverse") + + onHeaderClicked: { + if (delegateItem.instanceHomeUrl.length > 0) { + Qt.openUrlExternally(delegateItem.instanceHomeUrl) + } + } + onExpandedClicked: { + if (delegateItem.instanceHomeUrl.length > 0) { + Qt.openUrlExternally(delegateItem.instanceHomeUrl) + } + } + + onViewVisibleChanged: { + if (viewVisible) { + delegateItem.resetHasSyncableAccounts() + delegateItem.model.refresh() + if (delegateItem.hasSyncableAccounts) { + delegateItem.startPeriodicSyncLoop() + } + } else { + delegateItem.stopPeriodicSyncLoop() + } + } + + onConnectedToNetworkChanged: { + if (viewVisible) { + delegateItem.startPeriodicSyncLoop() + } + } + + Connections { + target: delegateItem.model + + onCountChanged: { + if (target.count === 0) { + delegateItem.instanceHomeUrl = "" + delegateItem.instanceIconPath = "" + } + } + } + + function statusUrl(modelData) { + var directUrl = modelData && modelData.url ? modelData.url.toString() : "" + if (directUrl.length > 0) { + return directUrl + } + + var instanceUrl = modelData && modelData.instanceUrl ? modelData.instanceUrl.toString() : "" + instanceUrl = stripTrailingSlashes(instanceUrl) + if (instanceUrl.length === 0) { + return "" + } + + var accountName = modelData && modelData.accountName ? modelData.accountName.toString() : "" + var statusId = modelData && modelData.fediverseId ? modelData.fediverseId.toString() : "" + if (accountName.length > 0 && statusId.length > 0) { + accountName = trimLeadingCharacter(accountName, "@") + return instanceUrl + "/@" + accountName + "/" + statusId + } + + return instanceUrl + "/explore" + } + + function authorizeInteractionUrl(modelData) { + var targetUrl = statusUrl(modelData) + if (targetUrl.length === 0) { + return targetUrl + } + + var instanceUrl = modelData && modelData.instanceUrl ? modelData.instanceUrl.toString() : "" + if (instanceUrl.length === 0) { + return targetUrl + } + instanceUrl = stripTrailingSlashes(instanceUrl) + + // Links on the user's own instance should open directly. + var sameServer = /^([a-z][a-z0-9+.-]*):\/\/([^\/?#]+)/i + var targetMatch = targetUrl.match(sameServer) + var instanceMatch = instanceUrl.match(sameServer) + if (targetMatch && instanceMatch + && targetMatch.length > 2 + && instanceMatch.length > 2 + && targetMatch[1].toLowerCase() === instanceMatch[1].toLowerCase() + && targetMatch[2].toLowerCase() === instanceMatch[2].toLowerCase()) { + return targetUrl + } + + return instanceUrl + "/authorize_interaction?uri=" + encodeURIComponent(targetUrl) + } + + function firstAccountId(modelData, defaultValue) { + var fallback = typeof defaultValue === "undefined" ? -1 : Number(defaultValue) + var accounts = modelData ? modelData.accounts : undefined + if (!accounts || accounts.length <= 0) { + return fallback + } + + var accountId = Number(accounts[0]) + return isNaN(accountId) ? fallback : accountId + } + + function stripTrailingSlashes(value) { + value = String(value || "") + while (value.length > 0 && value.charAt(value.length - 1) === "/") { + value = value.slice(0, value.length - 1) + } + return value + } + + function trimLeadingCharacter(value, character) { + value = String(value || "") + if (!character || character.length === 0) { + return value + } + + while (value.length > 0 && value.charAt(0) === character) { + value = value.substring(1) + } + return value + } +} diff --git a/eventsview-plugins/eventsview-plugin-fediverse/fediversepostactions.cpp b/eventsview-plugins/eventsview-plugin-fediverse/fediversepostactions.cpp new file mode 100644 index 0000000..2b370f2 --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-fediverse/fediversepostactions.cpp @@ -0,0 +1,317 @@ +/* + * Copyright (C) 2013-2026 Jolla Ltd. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#include "fediversepostactions.h" + +#include "fediverseauthutils.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + const char *const MicroblogServiceName = "fediverse-microblog"; +} + +FediversePostActions::FediversePostActions(QObject *parent) + : QObject(parent) + , m_accountManager(new Accounts::Manager(this)) +{ +} + +void FediversePostActions::favourite(int accountId, const QString &statusId) +{ + performAction(accountId, statusId, QStringLiteral("favourite")); +} + +void FediversePostActions::unfavourite(int accountId, const QString &statusId) +{ + performAction(accountId, statusId, QStringLiteral("unfavourite")); +} + +void FediversePostActions::boost(int accountId, const QString &statusId) +{ + performAction(accountId, statusId, QStringLiteral("reblog")); +} + +void FediversePostActions::unboost(int accountId, const QString &statusId) +{ + performAction(accountId, statusId, QStringLiteral("unreblog")); +} + +void FediversePostActions::performAction(int accountId, const QString &statusId, const QString &action) +{ + const QString trimmedStatusId = statusId.trimmed(); + if (accountId <= 0 || trimmedStatusId.isEmpty() || action.isEmpty()) { + emit actionFailed(accountId, trimmedStatusId, action, QStringLiteral("Invalid action request")); + return; + } + + const QString key = actionKey(accountId, trimmedStatusId, action); + if (m_pendingActions.contains(key)) { + return; + } + + Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this); + if (!account) { + emit actionFailed(accountId, trimmedStatusId, action, QStringLiteral("Unable to load account")); + return; + } + + const Accounts::Service service(m_accountManager->service(QString::fromLatin1(MicroblogServiceName))); + if (!service.isValid()) { + account->deleteLater(); + emit actionFailed(accountId, trimmedStatusId, action, QStringLiteral("Invalid account service")); + return; + } + + account->selectService(service); + SignOn::Identity *identity = account->credentialsId() > 0 + ? SignOn::Identity::existingIdentity(account->credentialsId()) + : 0; + if (!identity) { + account->deleteLater(); + emit actionFailed(accountId, trimmedStatusId, action, QStringLiteral("Missing account credentials")); + return; + } + + Accounts::AccountService accountService(account, service); + const QString method = accountService.authData().method(); + const QString mechanism = accountService.authData().mechanism(); + SignOn::AuthSession *session = identity->createSession(method); + if (!session) { + identity->deleteLater(); + account->deleteLater(); + emit actionFailed(accountId, trimmedStatusId, action, QStringLiteral("Unable to create auth session")); + return; + } + + QVariantMap signonSessionData = accountService.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(account)); + session->setProperty("identity", QVariant::fromValue(identity)); + session->setProperty("action", action); + session->setProperty("statusId", trimmedStatusId); + session->setProperty("accountId", accountId); + + m_pendingActions.insert(key); + session->process(SignOn::SessionData(signonSessionData), mechanism); +} + +void FediversePostActions::signOnResponse(const SignOn::SessionData &responseData) +{ + QObject *sessionObject = sender(); + SignOn::AuthSession *session = qobject_cast(sessionObject); + if (!session) { + return; + } + + const int accountId = session->property("accountId").toInt(); + const QString statusId = session->property("statusId").toString(); + const QString action = session->property("action").toString(); + const QString key = actionKey(accountId, statusId, action); + + const QVariantMap data = FediverseAuthUtils::responseDataToMap(responseData); + const QString accessToken = FediverseAuthUtils::accessToken(data); + + Accounts::Account *account = session->property("account").value(); + const QString apiHost = account + ? FediverseAuthUtils::normalizeApiHost(account->value(QStringLiteral("api/Host")).toString()) + : QString(); + + if (accessToken.isEmpty() || apiHost.isEmpty()) { + m_pendingActions.remove(key); + emit actionFailed(accountId, statusId, action, QStringLiteral("Missing access token")); + releaseSignOnObjects(sessionObject); + return; + } + + releaseSignOnObjects(sessionObject); + executeActionRequest(accountId, statusId, action, apiHost, accessToken); +} + +void FediversePostActions::signOnError(const SignOn::Error &error) +{ + QObject *sessionObject = sender(); + SignOn::AuthSession *session = qobject_cast(sessionObject); + if (!session) { + return; + } + + const int accountId = session->property("accountId").toInt(); + const QString statusId = session->property("statusId").toString(); + const QString action = session->property("action").toString(); + const QString key = actionKey(accountId, statusId, action); + m_pendingActions.remove(key); + + emit actionFailed(accountId, statusId, action, error.message()); + releaseSignOnObjects(sessionObject); +} + +void FediversePostActions::executeActionRequest(int accountId, + const QString &statusId, + const QString &action, + const QString &apiHost, + const QString &accessToken) +{ + const QString encodedStatusId = QString::fromLatin1(QUrl::toPercentEncoding(statusId)); + QUrl url(apiHost + QStringLiteral("/api/v1/statuses/") + + encodedStatusId + QStringLiteral("/") + action); + + QNetworkRequest request(url); + request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(accessToken).toUtf8()); + + QNetworkReply *reply = m_networkAccessManager.post(request, QByteArray()); + if (!reply) { + const QString key = actionKey(accountId, statusId, action); + m_pendingActions.remove(key); + emit actionFailed(accountId, statusId, action, QStringLiteral("Failed to start request")); + return; + } + + reply->setProperty("accountId", accountId); + reply->setProperty("statusId", statusId); + reply->setProperty("action", action); + connect(reply, SIGNAL(finished()), this, SLOT(actionFinishedHandler())); +} + +void FediversePostActions::actionFinishedHandler() +{ + QNetworkReply *reply = qobject_cast(sender()); + if (!reply) { + return; + } + + const int accountId = reply->property("accountId").toInt(); + const QString statusId = reply->property("statusId").toString(); + const QString action = reply->property("action").toString(); + const QString key = actionKey(accountId, statusId, action); + + const QByteArray data = reply->readAll(); + const bool hasError = reply->error() != QNetworkReply::NoError; + const QString errorString = reply->errorString(); + reply->deleteLater(); + + m_pendingActions.remove(key); + + if (hasError) { + emit actionFailed(accountId, statusId, action, errorString); + return; + } + + int favouritesCount = -1; + int reblogsCount = -1; + bool favourited = false; + bool reblogged = false; + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(data, &parseError); + if (parseError.error == QJsonParseError::NoError && document.isObject()) { + const QJsonObject object = document.object(); + QJsonObject metricsObject = object; + + const auto jsonObjectId = [](const QJsonObject &obj) -> QString { + return obj.value(QStringLiteral("id")).toVariant().toString().trimmed(); + }; + const QString requestedStatusId = statusId.trimmed(); + + const QJsonValue reblogValue = object.value(QStringLiteral("reblog")); + if (reblogValue.isObject() && !reblogValue.isNull()) { + const QJsonObject reblogObject = reblogValue.toObject(); + const QString nestedReblogId = jsonObjectId(reblogObject); + + if (nestedReblogId == requestedStatusId) { + metricsObject = reblogObject; + } + } + + favouritesCount = metricsObject.value(QStringLiteral("favourites_count")).toInt(-1); + if (favouritesCount < 0) { + favouritesCount = object.value(QStringLiteral("favourites_count")).toInt(-1); + } + + reblogsCount = metricsObject.value(QStringLiteral("reblogs_count")).toInt(-1); + if (reblogsCount < 0) { + reblogsCount = object.value(QStringLiteral("reblogs_count")).toInt(-1); + } + + if (metricsObject.contains(QStringLiteral("favourited"))) { + favourited = metricsObject.value(QStringLiteral("favourited")).toBool(false); + } else { + favourited = object.value(QStringLiteral("favourited")).toBool(false); + } + + if (metricsObject.contains(QStringLiteral("reblogged"))) { + reblogged = metricsObject.value(QStringLiteral("reblogged")).toBool(false); + } else { + reblogged = object.value(QStringLiteral("reblogged")).toBool(false); + } + + } + + emit actionSucceeded(accountId, statusId, action, + favouritesCount, reblogsCount, + favourited, reblogged); +} + +void FediversePostActions::releaseSignOnObjects(QObject *sessionObject) +{ + SignOn::AuthSession *session = qobject_cast(sessionObject); + if (!session) { + return; + } + + Accounts::Account *account = session->property("account").value(); + SignOn::Identity *identity = session->property("identity").value(); + + session->disconnect(this); + if (identity) { + identity->destroySession(session); + identity->deleteLater(); + } + if (account) { + account->deleteLater(); + } +} + +QString FediversePostActions::actionKey(int accountId, const QString &statusId, const QString &action) const +{ + return QString::number(accountId) + QLatin1Char(':') + statusId + QLatin1Char(':') + action; +} diff --git a/eventsview-plugins/eventsview-plugin-fediverse/fediversepostactions.h b/eventsview-plugins/eventsview-plugin-fediverse/fediversepostactions.h new file mode 100644 index 0000000..536d339 --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-fediverse/fediversepostactions.h @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2013-2026 Jolla Ltd. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef FEDIVERSEPOSTACTIONS_H +#define FEDIVERSEPOSTACTIONS_H + +#include +#include +#include +#include + +namespace Accounts { +class Account; +class Manager; +} + +namespace SignOn { +class Error; +class SessionData; +} + +class FediversePostActions : public QObject +{ + Q_OBJECT + +public: + explicit FediversePostActions(QObject *parent = 0); + + Q_INVOKABLE void favourite(int accountId, const QString &statusId); + Q_INVOKABLE void unfavourite(int accountId, const QString &statusId); + Q_INVOKABLE void boost(int accountId, const QString &statusId); + Q_INVOKABLE void unboost(int accountId, const QString &statusId); + +Q_SIGNALS: + void actionSucceeded(int accountId, + const QString &statusId, + const QString &action, + int favouritesCount, + int reblogsCount, + bool favourited, + bool reblogged); + void actionFailed(int accountId, + const QString &statusId, + const QString &action, + const QString &errorMessage); + +private Q_SLOTS: + void signOnResponse(const SignOn::SessionData &responseData); + void signOnError(const SignOn::Error &error); + void actionFinishedHandler(); + +private: + void performAction(int accountId, const QString &statusId, const QString &action); + void executeActionRequest(int accountId, + const QString &statusId, + const QString &action, + const QString &apiHost, + const QString &accessToken); + void releaseSignOnObjects(QObject *sessionObject); + QString actionKey(int accountId, const QString &statusId, const QString &action) const; + + Accounts::Manager *m_accountManager; + QNetworkAccessManager m_networkAccessManager; + QSet m_pendingActions; +}; + +#endif // FEDIVERSEPOSTACTIONS_H diff --git a/eventsview-plugins/eventsview-plugin-fediverse/fediversepostsmodel.cpp b/eventsview-plugins/eventsview-plugin-fediverse/fediversepostsmodel.cpp new file mode 100644 index 0000000..48b3446 --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-fediverse/fediversepostsmodel.cpp @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2013-2026 Jolla Ltd. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#include "fediversepostsmodel.h" + +#include + +namespace { + +QVariantList imageListForPost(const SocialPost::ConstPtr &post) +{ + QVariantList images; + if (!post) { + return images; + } + + const QList postImages = post->images(); + for (const SocialPostImage::ConstPtr &image : postImages) { + if (!image) { + continue; + } + + QVariantMap imageMap; + imageMap.insert(QStringLiteral("url"), image->url()); + imageMap.insert(QStringLiteral("type"), image->type() == SocialPostImage::Video + ? QStringLiteral("video") + : QStringLiteral("image")); + images.append(imageMap); + } + + return images; +} + +QVariantList accountListForPost(const SocialPost::ConstPtr &post) +{ + QVariantList accounts; + if (!post) { + return accounts; + } + + const QList postAccounts = post->accounts(); + for (int accountId : postAccounts) { + accounts.append(accountId); + } + return accounts; +} + +void appendCommonPostFields(QMap *eventMap, + const SocialPost::ConstPtr &post, + int idRole, + int nameRole, + int bodyRole, + int timestampRole, + int iconRole, + int imagesRole, + int accountsRole) +{ + if (!eventMap || !post) { + return; + } + + eventMap->insert(idRole, post->identifier()); + eventMap->insert(nameRole, post->name()); + eventMap->insert(bodyRole, post->body()); + eventMap->insert(timestampRole, post->timestamp()); + eventMap->insert(iconRole, post->icon()); + eventMap->insert(imagesRole, imageListForPost(post)); + eventMap->insert(accountsRole, accountListForPost(post)); +} + +} + +FediversePostsModel::FediversePostsModel(QObject *parent) + : QAbstractListModel(parent) +{ + connect(&m_database, &AbstractSocialPostCacheDatabase::postsChanged, + this, &FediversePostsModel::postsChanged); + connect(&m_database, SIGNAL(accountIdFilterChanged()), + this, SIGNAL(accountIdFilterChanged())); +} + +int FediversePostsModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + return m_data.count(); +} + +QVariant FediversePostsModel::data(const QModelIndex &index, int role) const +{ + const int row = index.row(); + if (!index.isValid() || row < 0 || row >= m_data.count()) { + return QVariant(); + } + + return m_data.at(row).value(role); +} + +QHash FediversePostsModel::roleNames() const +{ + QHash roleNames; + roleNames.insert(FediverseId, "fediverseId"); + roleNames.insert(Name, "name"); + roleNames.insert(AccountName, "accountName"); + roleNames.insert(Acct, "acct"); + roleNames.insert(Body, "body"); + roleNames.insert(Timestamp, "timestamp"); + roleNames.insert(Icon, "icon"); + roleNames.insert(Images, "images"); + roleNames.insert(Url, "url"); + roleNames.insert(Link, "link"); + roleNames.insert(BoostedBy, "boostedBy"); + roleNames.insert(RebloggedBy, "rebloggedBy"); + roleNames.insert(RepliesCount, "repliesCount"); + roleNames.insert(FavouritesCount, "favouritesCount"); + roleNames.insert(ReblogsCount, "reblogsCount"); + roleNames.insert(Favourited, "favourited"); + roleNames.insert(Reblogged, "reblogged"); + roleNames.insert(InstanceUrl, "instanceUrl"); + roleNames.insert(InstanceIconPath, "instanceIconPath"); + roleNames.insert(Accounts, "accounts"); + return roleNames; +} + +QVariantList FediversePostsModel::accountIdFilter() const +{ + return m_database.accountIdFilter(); +} + +void FediversePostsModel::setAccountIdFilter(const QVariantList &accountIds) +{ + m_database.setAccountIdFilter(accountIds); +} + +void FediversePostsModel::refresh() +{ + m_database.refresh(); +} + +void FediversePostsModel::postsChanged() +{ + QList data; + QList postsData = m_database.posts(); + Q_FOREACH (const SocialPost::ConstPtr &post, postsData) { + RowData eventMap; + const QString accountName = m_database.accountName(post); + const QString postUrl = m_database.url(post); + const QString boostedBy = m_database.boostedBy(post); + const int repliesCount = m_database.repliesCount(post); + const int favouritesCount = m_database.favouritesCount(post); + const int reblogsCount = m_database.reblogsCount(post); + const bool favourited = m_database.favourited(post); + const bool reblogged = m_database.reblogged(post); + + appendCommonPostFields(&eventMap, post, + FediversePostsModel::FediverseId, + FediversePostsModel::Name, + FediversePostsModel::Body, + FediversePostsModel::Timestamp, + FediversePostsModel::Icon, + FediversePostsModel::Images, + FediversePostsModel::Accounts); + eventMap.insert(FediversePostsModel::AccountName, accountName); + eventMap.insert(FediversePostsModel::Acct, accountName); + eventMap.insert(FediversePostsModel::Url, postUrl); + eventMap.insert(FediversePostsModel::Link, postUrl); + eventMap.insert(FediversePostsModel::BoostedBy, boostedBy); + eventMap.insert(FediversePostsModel::RebloggedBy, boostedBy); + eventMap.insert(FediversePostsModel::RepliesCount, repliesCount); + eventMap.insert(FediversePostsModel::FavouritesCount, favouritesCount); + eventMap.insert(FediversePostsModel::ReblogsCount, reblogsCount); + eventMap.insert(FediversePostsModel::Favourited, favourited); + eventMap.insert(FediversePostsModel::Reblogged, reblogged); + eventMap.insert(FediversePostsModel::InstanceUrl, m_database.instanceUrl(post)); + eventMap.insert(FediversePostsModel::InstanceIconPath, m_database.instanceIconPath(post)); + data.append(eventMap); + } + + const int oldCount = m_data.count(); + beginResetModel(); + m_data = data; + endResetModel(); + if (oldCount != m_data.count()) { + emit countChanged(); + } +} diff --git a/eventsview-plugins/eventsview-plugin-fediverse/fediversepostsmodel.h b/eventsview-plugins/eventsview-plugin-fediverse/fediversepostsmodel.h new file mode 100644 index 0000000..96acae3 --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-fediverse/fediversepostsmodel.h @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2013-2026 Jolla Ltd. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This 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 library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#ifndef FEDIVERSEPOSTSMODEL_H +#define FEDIVERSEPOSTSMODEL_H + +#include "fediversepostsdatabase.h" +#include +#include + +class FediversePostsModel: public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(QVariantList accountIdFilter READ accountIdFilter WRITE setAccountIdFilter NOTIFY accountIdFilterChanged) + Q_PROPERTY(int count READ rowCount NOTIFY countChanged) + +public: + enum FediversePostsRole { + FediverseId = 0, + Name, + AccountName, + Acct, + Body, + Timestamp, + Icon, + Images, + Url, + Link, + BoostedBy, + RebloggedBy, + RepliesCount, + FavouritesCount, + ReblogsCount, + Favourited, + Reblogged, + InstanceUrl, + InstanceIconPath, + Accounts + }; + + explicit FediversePostsModel(QObject *parent = 0); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash roleNames() const override; + + QVariantList accountIdFilter() const; + void setAccountIdFilter(const QVariantList &accountIds); + + Q_INVOKABLE void refresh(); + +signals: + void accountIdFilterChanged(); + void countChanged(); + +private slots: + void postsChanged(); + +private: + typedef QMap RowData; + QList m_data; + FediversePostsDatabase m_database; +}; + +#endif // FEDIVERSEPOSTSMODEL_H diff --git a/eventsview-plugins/eventsview-plugin-fediverse/plugin.cpp b/eventsview-plugins/eventsview-plugin-fediverse/plugin.cpp new file mode 100644 index 0000000..509b602 --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-fediverse/plugin.cpp @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#include +#include + +#include "fediversepostactions.h" +#include "fediversepostsmodel.h" + +class JollaEventsviewFediversePlugin : public QQmlExtensionPlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "com.jolla.eventsview.fediverse") + +public: + void registerTypes(const char *uri) override + { + Q_ASSERT(QLatin1String(uri) == QLatin1String("com.jolla.eventsview.fediverse")); + qmlRegisterType(uri, 1, 0, "FediversePostsModel"); + qmlRegisterType(uri, 1, 0, "FediversePostActions"); + } +}; + +#include "plugin.moc" diff --git a/eventsview-plugins/eventsview-plugin-fediverse/qmldir b/eventsview-plugins/eventsview-plugin-fediverse/qmldir new file mode 100644 index 0000000..8b25e83 --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-fediverse/qmldir @@ -0,0 +1,4 @@ +# Copyright (C) 2013-2026 Jolla Ltd. + +module com.jolla.eventsview.fediverse +plugin jollaeventsviewfediverseplugin diff --git a/eventsview-plugins/eventsview-plugin-mastodon/MastodonFeedItem.qml b/eventsview-plugins/eventsview-plugin-mastodon/MastodonFeedItem.qml deleted file mode 100644 index 63b9556..0000000 --- a/eventsview-plugins/eventsview-plugin-mastodon/MastodonFeedItem.qml +++ /dev/null @@ -1,488 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -import QtQuick 2.0 -import Sailfish.Silica 1.0 -import Sailfish.Share 1.0 -import Sailfish.TextLinking 1.0 -import org.nemomobile.lipstick 0.1 -import "shared" - -SocialMediaFeedItem { - id: item - - property variant imageList - property string resolvedStatusUrl: item.stringValue("url", "link", "uri") - property string postId - property QtObject postActions - property int likeCount: item.intValue("favouritesCount", "likeCount", "favoriteCount") - property int commentCount: item.intValue("repliesCount", "commentCount") - property int boostCount: item.intValue("reblogsCount", "boostCount", "repostsCount") - property bool favourited: !!model.favourited - property bool reblogged: !!model.reblogged - property int _likeCountOverride: -1 - property int _boostCountOverride: -1 - property int _favouritedOverride: -1 - property int _rebloggedOverride: -1 - property bool isFavourited: _favouritedOverride >= 0 ? _favouritedOverride === 1 : favourited - property bool isReblogged: _rebloggedOverride >= 0 ? _rebloggedOverride === 1 : reblogged - readonly property bool housekeeping: Lipstick.compositor.eventsLayer.housekeeping - readonly property bool lockScreenActive: Lipstick.compositor.lockScreenLayer.deviceIsLocked - property bool _pendingOpenActionMenu: false - property bool _contextMenuOpen: false - property var _actionMenu - property real _contextMenuHeight: (_contextMenuOpen && _actionMenu) ? _actionMenu.height : 0 - - property string _booster: model && model.boostedBy ? model.boostedBy.toString() : "" - property string _displayName: model && model.name ? model.name.toString() : "" - property string _accountName: model && model.accountName ? model.accountName.toString() : "" - property string _bodyText: model && model.body ? model.body.toString() : "" - //: Action label shown in Mastodon interaction menu. - //% "Share" - readonly property string _shareActionText: qsTrId("lipstick-jolla-home-la-mastodon_share") - //: Link title used when sharing a Mastodon post. - //% "Post from Mastodon" - readonly property string _shareLinkTitle: qsTrId("lipstick-jolla-home-la-mastodon_share_link_title") - property var _shareAction: ShareAction { - title: item._shareActionText - } - - timestamp: model.timestamp - onRefreshTimeCountChanged: formattedTime = Format.formatDate(model.timestamp, Format.TimeElapsed) - onLockScreenActiveChanged: { - if (lockScreenActive && _actionMenu) { - _actionMenu.close() - } - } - onPressAndHold: function(mouse) { - if (mouse) { - mouse.accepted = true - } - _pendingOpenActionMenu = !lockScreenActive - && postActions - && actionPostId().length > 0 - && actionAccountId() >= 0 - openActionMenuTimer.restart() - } - onHousekeepingChanged: { - if (housekeeping && _pendingOpenActionMenu) { - Lipstick.compositor.eventsLayer.setHousekeeping(false) - } - } - Component.onDestruction: { - if (_actionMenu) { - _actionMenu.destroy() - _actionMenu = null - } - } - - avatar.y: item._booster.length > 0 - ? topMargin + boosterIcon.height + Theme.paddingSmall - : topMargin - contentHeight: Math.max(content.y + content.height, avatar.y + avatar.height) + bottomMargin + _contextMenuHeight - topMargin: item._booster.length > 0 ? Theme.paddingMedium : Theme.paddingLarge - userRemovable: false - - Image { - id: boosterIcon - - anchors { - right: avatar.right - top: parent.top - topMargin: item.topMargin - } - visible: item._booster.length > 0 - source: "image://theme/icon-s-repost" + (item.highlighted ? "?" + Theme.highlightColor : "") - } - - Text { - anchors { - left: content.left - right: content.right - verticalCenter: boosterIcon.verticalCenter - } - elide: Text.ElideRight - font.pixelSize: Theme.fontSizeExtraSmall - color: item.highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor - textFormat: Text.PlainText - visible: text.length > 0 - - text: item._booster.length > 0 - ? //: Shown above a post that is boosted by another user. %1 = name of user who boosted - //% "%1 boosted" - qsTrId("lipstick-jolla-home-la-boosted_by").arg(item._booster) - : "" - } - - Column { - id: content - - anchors { - left: avatar.right - leftMargin: Theme.paddingMedium - top: avatar.top - } - width: parent.width - x - - Label { - width: parent.width - truncationMode: TruncationMode.Fade - text: item._displayName - color: item.highlighted ? Theme.highlightColor : Theme.primaryColor - textFormat: Text.PlainText - } - - Label { - width: parent.width - truncationMode: TruncationMode.Fade - text: item._accountName.length > 0 && item._accountName.charAt(0) !== "@" - ? "@" + item._accountName - : item._accountName - font.pixelSize: Theme.fontSizeSmall - color: item.highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor - textFormat: Text.PlainText - } - - LinkedText { - width: parent.width - elide: Text.ElideRight - wrapMode: Text.Wrap - font.pixelSize: Theme.fontSizeSmall - shortenUrl: true - color: item.highlighted ? Theme.highlightColor : Theme.primaryColor - linkColor: Theme.highlightColor - plainText: item._bodyText - } - - Row { - id: metadataRow - - width: parent.width - height: previewRow.visible ? implicitHeight + Theme.paddingMedium : implicitHeight // add padding below - spacing: Theme.paddingSmall - - readonly property color passiveColor: item.highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor - readonly property color activeColor: Theme.highlightColor - - Label { - font.pixelSize: Theme.fontSizeExtraSmall - text: "↩ " + item.commentCount - color: metadataRow.passiveColor - } - - Label { - font.pixelSize: Theme.fontSizeExtraSmall - text: "|" - color: metadataRow.passiveColor - } - - Label { - font.pixelSize: Theme.fontSizeExtraSmall - text: "★ " + (item._likeCountOverride >= 0 ? item._likeCountOverride : item.likeCount) - color: item.isFavourited ? metadataRow.activeColor : metadataRow.passiveColor - } - - Label { - font.pixelSize: Theme.fontSizeExtraSmall - text: "|" - color: metadataRow.passiveColor - } - - Label { - font.pixelSize: Theme.fontSizeExtraSmall - text: "↻ " + (item._boostCountOverride >= 0 ? item._boostCountOverride : item.boostCount) - color: item.isReblogged ? metadataRow.activeColor : metadataRow.passiveColor - } - - Label { - visible: item.formattedTime.length > 0 - font.pixelSize: Theme.fontSizeExtraSmall - text: "|" - color: metadataRow.passiveColor - } - - Label { - visible: item.formattedTime.length > 0 - width: Math.max(0, metadataRow.width - x) - truncationMode: TruncationMode.Fade - font.pixelSize: Theme.fontSizeExtraSmall - text: item.formattedTime - color: metadataRow.passiveColor - } - } - - SocialMediaPreviewRow { - id: previewRow - - width: parent.width + Theme.horizontalPageMargin // extend to right edge of notification area - imageList: item.imageList - downloader: item.downloader - accountId: item.accountId - connectedToNetwork: item.connectedToNetwork - highlighted: item.highlighted - eventsColumnMaxWidth: item.eventsColumnMaxWidth - item.avatar.width - } - } - - function stringValue() { - for (var i = 0; i < arguments.length; ++i) { - var value = model[arguments[i]] - if (typeof value === "undefined" || value === null) { - continue - } - value = String(value) - if (value.length > 0) { - return value - } - } - return "" - } - - function intValue() { - for (var i = 0; i < arguments.length; ++i) { - var value = model[arguments[i]] - if (typeof value === "undefined" || value === null) { - continue - } - var number = Number(value) - if (!isNaN(number)) { - return Math.max(0, Math.floor(number)) - } - } - return 0 - } - - function actionPostId() { - if (item.postId.length > 0) { - return item.postId - } - return item.stringValue("mastodonId", "statusId", "id", "twitterId") - } - - function actionAccountId() { - var parsed = Number(item.accountId) - return isNaN(parsed) ? -1 : parsed - } - - function shareStatusUrl() { - return item.stringValue("url", "link", "uri") - } - - function topLevelParent() { - var p = item - while (p && p.parent) { - p = p.parent - } - return p - } - - function openActionMenu() { - if (_actionMenu) { - _actionMenu.destroy() - _actionMenu = null - } - - var parentItem = topLevelParent() - _actionMenu = actionMenuComponent.createObject(parentItem) - if (_actionMenu) { - _actionMenu.open(item) - } - } - - Connections { - target: item.postActions ? item.postActions : null - - onActionSucceeded: { - if (accountId !== item.actionAccountId() || statusId !== item.actionPostId()) { - return - } - - if (favouritesCount >= 0) { - item._likeCountOverride = favouritesCount - } - if (reblogsCount >= 0) { - item._boostCountOverride = reblogsCount - } - item._favouritedOverride = favourited ? 1 : 0 - item._rebloggedOverride = reblogged ? 1 : 0 - item._contextMenuOpen = false - - if (item._accountDelegate) { - item._accountDelegate.sync() - } - } - - onActionFailed: { - if (accountId !== item.actionAccountId() || statusId !== item.actionPostId()) { - return - } - console.warn("Mastodon action failed:", action, errorMessage) - item._contextMenuOpen = false - } - } - - Component { - id: actionMenuComponent - - ContextMenu { - id: actionMenu - property bool menuOpen: height > 0 - property bool wasOpened: false - z: 10000 - - onPositionChanged: { - horizontalActions.xPos = _contentColumn.mapFromItem(actionMenu, mouse.x, mouse.y).x - } - - onMenuOpenChanged: { - if (menuOpen) { - wasOpened = true - item._contextMenuOpen = true - } else if (wasOpened) { - item._contextMenuOpen = false - destroy() - item._actionMenu = null - } - } - - Item { - id: horizontalActions - - // Makes Silica treat this custom row as a context-menu item. - property int __silica_menuitem - property bool down - property bool highlighted - signal clicked - - property real xPos: 0 - property int hoveredIndex: -1 - readonly property bool actionEnabled: item.postActions - && item.actionPostId().length > 0 - && item.actionAccountId() >= 0 - && !item.lockScreenActive - && !item.housekeeping - - width: parent.width - height: Theme.itemSizeMedium - - onXPosChanged: hoveredIndex = Math.max(0, Math.min(2, Math.floor((xPos * 3) / Math.max(1, width)))) - onDownChanged: if (!down) hoveredIndex = -1 - - onClicked: { - xPos = _contentColumn.mapFromItem(actionMenu, actionMenu.mouseX, actionMenu.mouseY).x - var index = hoveredIndex >= 0 ? hoveredIndex : Math.max(0, Math.min(2, Math.floor((xPos * 3) / Math.max(1, width)))) - if (!actionEnabled) { - return - } - var postId = item.actionPostId() - var accountId = item.actionAccountId() - if (index === 0) { - if (item.isFavourited) { - item.postActions.unfavourite(accountId, postId) - } else { - item.postActions.favourite(accountId, postId) - } - } else if (index === 1) { - if (item.isReblogged) { - item.postActions.unboost(accountId, postId) - } else { - item.postActions.boost(accountId, postId) - } - } else { - var shareUrl = item.shareStatusUrl() - if (shareUrl.length === 0) { - return - } - item._shareAction.resources = [{ - "data": shareUrl, - "linkTitle": item._shareLinkTitle, - "type": "text/x-url" - }] - item._shareAction.trigger() - } - } - - Rectangle { - anchors.verticalCenter: parent.verticalCenter - x: (horizontalActions.hoveredIndex >= 0 ? horizontalActions.hoveredIndex : 0) * (parent.width / 3) - width: parent.width / 3 - height: parent.height - visible: horizontalActions.down && horizontalActions.hoveredIndex >= 0 - color: Theme.rgba(Theme.highlightBackgroundColor, Theme.highlightBackgroundOpacity) - } - - Row { - anchors.fill: parent - - Label { - width: parent.width / 3 - height: parent.height - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - font.pixelSize: Theme.fontSizeExtraLarge - text: "★" - color: horizontalActions.actionEnabled - ? (item.isFavourited - ? Theme.highlightColor - : ((horizontalActions.down && horizontalActions.hoveredIndex === 0) - || (horizontalActions.highlighted && horizontalActions.hoveredIndex === 0) - ? Theme.secondaryHighlightColor : Theme.primaryColor)) - : Theme.rgba(Theme.secondaryColor, 0.4) - } - - Label { - width: parent.width / 3 - height: parent.height - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - font.pixelSize: Theme.fontSizeExtraLarge - text: "↻" - color: horizontalActions.actionEnabled - ? (item.isReblogged - ? Theme.highlightColor - : ((horizontalActions.down && horizontalActions.hoveredIndex === 1) - || (horizontalActions.highlighted && horizontalActions.hoveredIndex === 1) - ? Theme.secondaryHighlightColor : Theme.primaryColor)) - : Theme.rgba(Theme.secondaryColor, 0.4) - } - - Label { - width: parent.width / 3 - height: parent.height - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - font.pixelSize: Theme.fontSizeExtraLarge - text: "\u260D" - color: horizontalActions.actionEnabled - ? (((horizontalActions.down && horizontalActions.hoveredIndex === 2) - || (horizontalActions.highlighted && horizontalActions.hoveredIndex === 2)) - ? Theme.secondaryHighlightColor : Theme.primaryColor) - : Theme.rgba(Theme.secondaryColor, 0.4) - } - } - } - } - } - - Timer { - id: openActionMenuTimer - - interval: 0 - repeat: false - onTriggered: { - if (item.lockScreenActive) { - item._pendingOpenActionMenu = false - return - } - Lipstick.compositor.eventsLayer.setHousekeeping(false) - if (item._pendingOpenActionMenu) { - item._contextMenuOpen = false - item.openActionMenu() - } - item._pendingOpenActionMenu = false - } - } -} diff --git a/eventsview-plugins/eventsview-plugin-mastodon/eventsview-plugin-mastodon.pro b/eventsview-plugins/eventsview-plugin-mastodon/eventsview-plugin-mastodon.pro deleted file mode 100644 index 04be215..0000000 --- a/eventsview-plugins/eventsview-plugin-mastodon/eventsview-plugin-mastodon.pro +++ /dev/null @@ -1,64 +0,0 @@ -# SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. -# -# SPDX-License-Identifier: BSD-3-Clause - -TEMPLATE = lib -TARGET = jollaeventsviewmastodonplugin -TARGET = $$qtLibraryTarget($$TARGET) - -MODULENAME = com/jolla/eventsview/mastodon -TARGETPATH = $$[QT_INSTALL_QML]/$$MODULENAME - -QT += qml network -CONFIG += plugin link_pkgconfig -PKGCONFIG += socialcache accounts-qt5 libsignon-qt5 sailfishaccounts - -include($$PWD/../../common/common.pri) - -TS_FILE = $$OUT_PWD/lipstick-jolla-home-mastodon.ts -EE_QM = $$OUT_PWD/lipstick-jolla-home-mastodon_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 - -# should add -markuntranslated "-" when proper translations are in place (or for testing) -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 - -INSTALLS += ts_install engineering_english_install - -HEADERS += \ - mastodonpostactions.h \ - mastodonpostsmodel.h - -SOURCES += \ - mastodonpostactions.cpp \ - mastodonpostsmodel.cpp \ - plugin.cpp - -qml.files = mastodon-delegate.qml MastodonFeedItem.qml -qml.path = /usr/share/lipstick/eventfeed/ - -import.files = qmldir -import.path = $$TARGETPATH -target.path = $$TARGETPATH - -OTHER_FILES += $$qml.files $$import.files - -INSTALLS += target import qml diff --git a/eventsview-plugins/eventsview-plugin-mastodon/mastodon-delegate.qml b/eventsview-plugins/eventsview-plugin-mastodon/mastodon-delegate.qml deleted file mode 100644 index fac0b89..0000000 --- a/eventsview-plugins/eventsview-plugin-mastodon/mastodon-delegate.qml +++ /dev/null @@ -1,214 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -import QtQuick 2.0 -import Sailfish.Silica 1.0 -import org.nemomobile.socialcache 1.0 -import com.jolla.eventsview.mastodon 1.0 -import QtQml.Models 2.1 -import "shared" - -SocialMediaAccountDelegate { - id: delegateItem - - //: Mastodon posts - //% "Posts" - headerText: qsTrId("lipstick-jolla-home-la-mastodon_posts") - headerIcon: "image://theme/icon-l-mastodon" - showRemainingCount: false - - services: ["Posts"] - socialNetwork: 9 - dataType: SocialSync.Posts - providerName: "mastodon" - - MastodonPostActions { - id: mastodonPostActions - } - - model: MastodonPostsModel { - onCountChanged: { - if (count > 0) { - if (!updateTimer.running) { - shortUpdateTimer.start() - } - } else { - shortUpdateTimer.stop() - } - } - } - - delegate: MastodonFeedItem { - downloader: delegateItem.downloader - imageList: delegateItem.variantRole(model, ["images", "mediaAttachments", "media"]) - avatarSource: delegateItem.convertUrl(delegateItem.stringRole(model, ["icon", "avatar", "avatarUrl"])) - fallbackAvatarSource: delegateItem.stringRole(model, ["icon", "avatar", "avatarUrl"]) - resolvedStatusUrl: delegateItem.authorizeInteractionUrl(model) - postId: delegateItem.stringRole(model, ["mastodonId", "statusId", "id", "twitterId"]) - postActions: mastodonPostActions - accountId: delegateItem.firstAccountId(model) - - onTriggered: Qt.openUrlExternally(resolvedStatusUrl) - - Component.onCompleted: { - refreshTimeCount = Qt.binding(function() { return delegateItem.refreshTimeCount }) - connectedToNetwork = Qt.binding(function() { return delegateItem.connectedToNetwork }) - eventsColumnMaxWidth = Qt.binding(function() { return delegateItem.eventsColumnMaxWidth }) - } - } - //% "Show more in Mastodon" - expandedLabel: qsTrId("lipstick-jolla-home-la-show-more-in-mastodon") - - onHeaderClicked: Qt.openUrlExternally("https://mastodon.social/explore") - onExpandedClicked: Qt.openUrlExternally("https://mastodon.social/explore") - - onViewVisibleChanged: { - if (viewVisible) { - delegateItem.resetHasSyncableAccounts() - delegateItem.model.refresh() - if (delegateItem.hasSyncableAccounts && !updateTimer.running) { - shortUpdateTimer.start() - } - } else { - shortUpdateTimer.stop() - } - } - - onConnectedToNetworkChanged: { - if (viewVisible) { - if (!updateTimer.running) { - shortUpdateTimer.start() - } - } - } - - // The Mastodon feed is updated 3 seconds after the feed view becomes visible, - // unless it has been updated during last 60 seconds. After that it will be updated - // periodically in every 60 seconds as long as the feed view is visible. - - Timer { - id: shortUpdateTimer - - interval: 3000 - onTriggered: { - delegateItem.sync() - updateTimer.start() - } - } - - Timer { - id: updateTimer - - interval: 60000 - repeat: true - onTriggered: { - if (delegateItem.viewVisible) { - delegateItem.sync() - } else { - stop() - } - } - } - - function variantRole(modelData, roleNames) { - for (var i = 0; i < roleNames.length; ++i) { - var value = modelData[roleNames[i]] - if (typeof value !== "undefined" && value !== null) { - return value - } - } - return undefined - } - - function stringRole(modelData, roleNames) { - for (var i = 0; i < roleNames.length; ++i) { - var value = modelData[roleNames[i]] - if (typeof value === "undefined" || value === null) { - continue - } - value = String(value) - if (value.length > 0) { - return value - } - } - return "" - } - - function statusUrl(modelData) { - var directUrl = stringRole(modelData, ["url", "link", "uri"]) - if (directUrl.length > 0) { - return directUrl - } - - var instanceUrl = stringRole(modelData, ["instanceUrl", "serverUrl", "baseUrl"]) - if (instanceUrl.length === 0) { - instanceUrl = "https://mastodon.social" - } - while (instanceUrl.length > 0 && instanceUrl.charAt(instanceUrl.length - 1) === "/") { - instanceUrl = instanceUrl.slice(0, instanceUrl.length - 1) - } - - var accountName = stringRole(modelData, ["accountName", "acct", "screenName", "username"]) - var statusId = stringRole(modelData, ["mastodonId", "statusId", "id", "twitterId"]) - if (accountName.length > 0 && statusId.length > 0) { - while (accountName.length > 0 && accountName.charAt(0) === "@") { - accountName = accountName.substring(1) - } - return instanceUrl + "/@" + accountName + "/" + statusId - } - - return instanceUrl + "/explore" - } - - function authorizeInteractionUrl(modelData) { - var targetUrl = statusUrl(modelData) - if (targetUrl.length === 0) { - return targetUrl - } - - var instanceUrl = stringRole(modelData, ["instanceUrl", "serverUrl", "baseUrl"]) - if (instanceUrl.length === 0) { - return targetUrl - } - while (instanceUrl.length > 0 && instanceUrl.charAt(instanceUrl.length - 1) === "/") { - instanceUrl = instanceUrl.slice(0, instanceUrl.length - 1) - } - - // Links on the user's own instance should open directly. - var sameServer = /^([a-z][a-z0-9+.-]*):\/\/([^\/?#]+)/i - var targetMatch = targetUrl.match(sameServer) - var instanceMatch = instanceUrl.match(sameServer) - if (targetMatch && instanceMatch - && targetMatch.length > 2 - && instanceMatch.length > 2 - && targetMatch[1].toLowerCase() === instanceMatch[1].toLowerCase() - && targetMatch[2].toLowerCase() === instanceMatch[2].toLowerCase()) { - return targetUrl - } - - return instanceUrl + "/authorize_interaction?uri=" + encodeURIComponent(targetUrl) - } - - function convertUrl(source) { - if (source.indexOf("_normal.") !== -1) { - return source.replace("_normal.", "_bigger.") - } else if (source.indexOf("_mini.") !== -1) { - return source.replace("_mini.", "_bigger.") - } - return source - } - - function firstAccountId(modelData) { - var accounts = modelData.accounts - if (accounts && accounts.length > 0) { - var accountId = Number(accounts[0]) - if (!isNaN(accountId)) { - return accountId - } - } - return -1 - } -} diff --git a/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostactions.cpp b/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostactions.cpp deleted file mode 100644 index 371b7dd..0000000 --- a/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostactions.cpp +++ /dev/null @@ -1,317 +0,0 @@ -/* - * Copyright (C) 2013-2026 Jolla Ltd. - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This 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 library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -#include "mastodonpostactions.h" - -#include "mastodonauthutils.h" - -#include -#include -#include -#include - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace { - const char *const MicroblogServiceName = "mastodon-microblog"; -} - -MastodonPostActions::MastodonPostActions(QObject *parent) - : QObject(parent) - , m_accountManager(new Accounts::Manager(this)) -{ -} - -void MastodonPostActions::favourite(int accountId, const QString &statusId) -{ - performAction(accountId, statusId, QStringLiteral("favourite")); -} - -void MastodonPostActions::unfavourite(int accountId, const QString &statusId) -{ - performAction(accountId, statusId, QStringLiteral("unfavourite")); -} - -void MastodonPostActions::boost(int accountId, const QString &statusId) -{ - performAction(accountId, statusId, QStringLiteral("reblog")); -} - -void MastodonPostActions::unboost(int accountId, const QString &statusId) -{ - performAction(accountId, statusId, QStringLiteral("unreblog")); -} - -void MastodonPostActions::performAction(int accountId, const QString &statusId, const QString &action) -{ - const QString trimmedStatusId = statusId.trimmed(); - if (accountId <= 0 || trimmedStatusId.isEmpty() || action.isEmpty()) { - emit actionFailed(accountId, trimmedStatusId, action, QStringLiteral("Invalid action request")); - return; - } - - const QString key = actionKey(accountId, trimmedStatusId, action); - if (m_pendingActions.contains(key)) { - return; - } - - Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this); - if (!account) { - emit actionFailed(accountId, trimmedStatusId, action, QStringLiteral("Unable to load account")); - return; - } - - const Accounts::Service service(m_accountManager->service(QString::fromLatin1(MicroblogServiceName))); - if (!service.isValid()) { - account->deleteLater(); - emit actionFailed(accountId, trimmedStatusId, action, QStringLiteral("Invalid account service")); - return; - } - - account->selectService(service); - SignOn::Identity *identity = account->credentialsId() > 0 - ? SignOn::Identity::existingIdentity(account->credentialsId()) - : 0; - if (!identity) { - account->deleteLater(); - emit actionFailed(accountId, trimmedStatusId, action, QStringLiteral("Missing account credentials")); - return; - } - - Accounts::AccountService accountService(account, service); - const QString method = accountService.authData().method(); - const QString mechanism = accountService.authData().mechanism(); - SignOn::AuthSession *session = identity->createSession(method); - if (!session) { - identity->deleteLater(); - account->deleteLater(); - emit actionFailed(accountId, trimmedStatusId, action, QStringLiteral("Unable to create auth session")); - return; - } - - QVariantMap signonSessionData = accountService.authData().parameters(); - MastodonAuthUtils::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(account)); - session->setProperty("identity", QVariant::fromValue(identity)); - session->setProperty("action", action); - session->setProperty("statusId", trimmedStatusId); - session->setProperty("accountId", accountId); - - m_pendingActions.insert(key); - session->process(SignOn::SessionData(signonSessionData), mechanism); -} - -void MastodonPostActions::signOnResponse(const SignOn::SessionData &responseData) -{ - QObject *sessionObject = sender(); - SignOn::AuthSession *session = qobject_cast(sessionObject); - if (!session) { - return; - } - - const int accountId = session->property("accountId").toInt(); - const QString statusId = session->property("statusId").toString(); - const QString action = session->property("action").toString(); - const QString key = actionKey(accountId, statusId, action); - - const QVariantMap data = MastodonAuthUtils::responseDataToMap(responseData); - const QString accessToken = MastodonAuthUtils::accessToken(data); - - Accounts::Account *account = session->property("account").value(); - const QString apiHost = account - ? MastodonAuthUtils::normalizeApiHost(account->value(QStringLiteral("api/Host")).toString()) - : QString(); - - if (accessToken.isEmpty() || apiHost.isEmpty()) { - m_pendingActions.remove(key); - emit actionFailed(accountId, statusId, action, QStringLiteral("Missing access token")); - releaseSignOnObjects(sessionObject); - return; - } - - releaseSignOnObjects(sessionObject); - executeActionRequest(accountId, statusId, action, apiHost, accessToken); -} - -void MastodonPostActions::signOnError(const SignOn::Error &error) -{ - QObject *sessionObject = sender(); - SignOn::AuthSession *session = qobject_cast(sessionObject); - if (!session) { - return; - } - - const int accountId = session->property("accountId").toInt(); - const QString statusId = session->property("statusId").toString(); - const QString action = session->property("action").toString(); - const QString key = actionKey(accountId, statusId, action); - m_pendingActions.remove(key); - - emit actionFailed(accountId, statusId, action, error.message()); - releaseSignOnObjects(sessionObject); -} - -void MastodonPostActions::executeActionRequest(int accountId, - const QString &statusId, - const QString &action, - const QString &apiHost, - const QString &accessToken) -{ - const QString encodedStatusId = QString::fromLatin1(QUrl::toPercentEncoding(statusId)); - QUrl url(apiHost + QStringLiteral("/api/v1/statuses/") - + encodedStatusId + QStringLiteral("/") + action); - - QNetworkRequest request(url); - request.setRawHeader("Authorization", QStringLiteral("Bearer %1").arg(accessToken).toUtf8()); - - QNetworkReply *reply = m_networkAccessManager.post(request, QByteArray()); - if (!reply) { - const QString key = actionKey(accountId, statusId, action); - m_pendingActions.remove(key); - emit actionFailed(accountId, statusId, action, QStringLiteral("Failed to start request")); - return; - } - - reply->setProperty("accountId", accountId); - reply->setProperty("statusId", statusId); - reply->setProperty("action", action); - connect(reply, SIGNAL(finished()), this, SLOT(actionFinishedHandler())); -} - -void MastodonPostActions::actionFinishedHandler() -{ - QNetworkReply *reply = qobject_cast(sender()); - if (!reply) { - return; - } - - const int accountId = reply->property("accountId").toInt(); - const QString statusId = reply->property("statusId").toString(); - const QString action = reply->property("action").toString(); - const QString key = actionKey(accountId, statusId, action); - - const QByteArray data = reply->readAll(); - const bool hasError = reply->error() != QNetworkReply::NoError; - const QString errorString = reply->errorString(); - reply->deleteLater(); - - m_pendingActions.remove(key); - - if (hasError) { - emit actionFailed(accountId, statusId, action, errorString); - return; - } - - int favouritesCount = -1; - int reblogsCount = -1; - bool favourited = false; - bool reblogged = false; - - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson(data, &parseError); - if (parseError.error == QJsonParseError::NoError && document.isObject()) { - const QJsonObject object = document.object(); - QJsonObject metricsObject = object; - - const auto jsonObjectId = [](const QJsonObject &obj) -> QString { - return obj.value(QStringLiteral("id")).toVariant().toString().trimmed(); - }; - const QString requestedStatusId = statusId.trimmed(); - - const QJsonValue reblogValue = object.value(QStringLiteral("reblog")); - if (reblogValue.isObject() && !reblogValue.isNull()) { - const QJsonObject reblogObject = reblogValue.toObject(); - const QString nestedReblogId = jsonObjectId(reblogObject); - - if (nestedReblogId == requestedStatusId) { - metricsObject = reblogObject; - } - } - - favouritesCount = metricsObject.value(QStringLiteral("favourites_count")).toInt(-1); - if (favouritesCount < 0) { - favouritesCount = object.value(QStringLiteral("favourites_count")).toInt(-1); - } - - reblogsCount = metricsObject.value(QStringLiteral("reblogs_count")).toInt(-1); - if (reblogsCount < 0) { - reblogsCount = object.value(QStringLiteral("reblogs_count")).toInt(-1); - } - - if (metricsObject.contains(QStringLiteral("favourited"))) { - favourited = metricsObject.value(QStringLiteral("favourited")).toBool(false); - } else { - favourited = object.value(QStringLiteral("favourited")).toBool(false); - } - - if (metricsObject.contains(QStringLiteral("reblogged"))) { - reblogged = metricsObject.value(QStringLiteral("reblogged")).toBool(false); - } else { - reblogged = object.value(QStringLiteral("reblogged")).toBool(false); - } - - } - - emit actionSucceeded(accountId, statusId, action, - favouritesCount, reblogsCount, - favourited, reblogged); -} - -void MastodonPostActions::releaseSignOnObjects(QObject *sessionObject) -{ - SignOn::AuthSession *session = qobject_cast(sessionObject); - if (!session) { - return; - } - - Accounts::Account *account = session->property("account").value(); - SignOn::Identity *identity = session->property("identity").value(); - - session->disconnect(this); - if (identity) { - identity->destroySession(session); - identity->deleteLater(); - } - if (account) { - account->deleteLater(); - } -} - -QString MastodonPostActions::actionKey(int accountId, const QString &statusId, const QString &action) const -{ - return QString::number(accountId) + QLatin1Char(':') + statusId + QLatin1Char(':') + action; -} diff --git a/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostactions.h b/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostactions.h deleted file mode 100644 index cfe0c2a..0000000 --- a/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostactions.h +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (C) 2013-2026 Jolla Ltd. - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This 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 library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -#ifndef MASTODONPOSTACTIONS_H -#define MASTODONPOSTACTIONS_H - -#include -#include -#include -#include - -namespace Accounts { -class Account; -class Manager; -} - -namespace SignOn { -class Error; -class SessionData; -} - -class MastodonPostActions : public QObject -{ - Q_OBJECT - -public: - explicit MastodonPostActions(QObject *parent = 0); - - Q_INVOKABLE void favourite(int accountId, const QString &statusId); - Q_INVOKABLE void unfavourite(int accountId, const QString &statusId); - Q_INVOKABLE void boost(int accountId, const QString &statusId); - Q_INVOKABLE void unboost(int accountId, const QString &statusId); - -Q_SIGNALS: - void actionSucceeded(int accountId, - const QString &statusId, - const QString &action, - int favouritesCount, - int reblogsCount, - bool favourited, - bool reblogged); - void actionFailed(int accountId, - const QString &statusId, - const QString &action, - const QString &errorMessage); - -private Q_SLOTS: - void signOnResponse(const SignOn::SessionData &responseData); - void signOnError(const SignOn::Error &error); - void actionFinishedHandler(); - -private: - void performAction(int accountId, const QString &statusId, const QString &action); - void executeActionRequest(int accountId, - const QString &statusId, - const QString &action, - const QString &apiHost, - const QString &accessToken); - void releaseSignOnObjects(QObject *sessionObject); - QString actionKey(int accountId, const QString &statusId, const QString &action) const; - - Accounts::Manager *m_accountManager; - QNetworkAccessManager m_networkAccessManager; - QSet m_pendingActions; -}; - -#endif // MASTODONPOSTACTIONS_H diff --git a/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.cpp b/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.cpp deleted file mode 100644 index aa98a95..0000000 --- a/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.cpp +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright (C) 2013-2026 Jolla Ltd. - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This 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 library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -#include "mastodonpostsmodel.h" -#include - -namespace { - -static const char *URL_KEY = "url"; -static const char *TYPE_KEY = "type"; -static const char *TYPE_PHOTO = "photo"; -static const char *TYPE_VIDEO = "video"; - -QVariantMap createImageData(const SocialPostImage::ConstPtr &image) -{ - QVariantMap imageData; - imageData.insert(QLatin1String(URL_KEY), image->url()); - switch (image->type()) { - case SocialPostImage::Video: - imageData.insert(QLatin1String(TYPE_KEY), QLatin1String(TYPE_VIDEO)); - break; - default: - imageData.insert(QLatin1String(TYPE_KEY), QLatin1String(TYPE_PHOTO)); - break; - } - return imageData; -} - -} - -MastodonPostsModel::MastodonPostsModel(QObject *parent) - : QAbstractListModel(parent) -{ - connect(&m_database, &AbstractSocialPostCacheDatabase::postsChanged, - this, &MastodonPostsModel::postsChanged); - connect(&m_database, SIGNAL(accountIdFilterChanged()), - this, SIGNAL(accountIdFilterChanged())); -} - -int MastodonPostsModel::rowCount(const QModelIndex &parent) const -{ - Q_UNUSED(parent) - return m_data.count(); -} - -QVariant MastodonPostsModel::data(const QModelIndex &index, int role) const -{ - const int row = index.row(); - if (!index.isValid() || row < 0 || row >= m_data.count()) { - return QVariant(); - } - - return m_data.at(row).value(role); -} - -QHash MastodonPostsModel::roleNames() const -{ - QHash roleNames; - roleNames.insert(MastodonId, "mastodonId"); - roleNames.insert(Name, "name"); - roleNames.insert(AccountName, "accountName"); - roleNames.insert(Acct, "acct"); - roleNames.insert(Body, "body"); - roleNames.insert(Timestamp, "timestamp"); - roleNames.insert(Icon, "icon"); - roleNames.insert(Images, "images"); - roleNames.insert(Url, "url"); - roleNames.insert(Link, "link"); - roleNames.insert(BoostedBy, "boostedBy"); - roleNames.insert(RebloggedBy, "rebloggedBy"); - roleNames.insert(RepliesCount, "repliesCount"); - roleNames.insert(FavouritesCount, "favouritesCount"); - roleNames.insert(ReblogsCount, "reblogsCount"); - roleNames.insert(Favourited, "favourited"); - roleNames.insert(Reblogged, "reblogged"); - roleNames.insert(InstanceUrl, "instanceUrl"); - roleNames.insert(Accounts, "accounts"); - return roleNames; -} - -QVariantList MastodonPostsModel::accountIdFilter() const -{ - return m_database.accountIdFilter(); -} - -void MastodonPostsModel::setAccountIdFilter(const QVariantList &accountIds) -{ - m_database.setAccountIdFilter(accountIds); -} - -void MastodonPostsModel::refresh() -{ - m_database.refresh(); -} - -void MastodonPostsModel::postsChanged() -{ - QList data; - QList postsData = m_database.posts(); - Q_FOREACH (const SocialPost::ConstPtr &post, postsData) { - RowData eventMap; - const QString accountName = m_database.accountName(post); - const QString postUrl = m_database.url(post); - const QString boostedBy = m_database.boostedBy(post); - const int repliesCount = m_database.repliesCount(post); - const int favouritesCount = m_database.favouritesCount(post); - const int reblogsCount = m_database.reblogsCount(post); - const bool favourited = m_database.favourited(post); - const bool reblogged = m_database.reblogged(post); - - eventMap.insert(MastodonPostsModel::MastodonId, post->identifier()); - eventMap.insert(MastodonPostsModel::Name, post->name()); - eventMap.insert(MastodonPostsModel::AccountName, accountName); - eventMap.insert(MastodonPostsModel::Acct, accountName); - eventMap.insert(MastodonPostsModel::Body, post->body()); - eventMap.insert(MastodonPostsModel::Timestamp, post->timestamp()); - eventMap.insert(MastodonPostsModel::Icon, post->icon()); - eventMap.insert(MastodonPostsModel::Url, postUrl); - eventMap.insert(MastodonPostsModel::Link, postUrl); - eventMap.insert(MastodonPostsModel::BoostedBy, boostedBy); - eventMap.insert(MastodonPostsModel::RebloggedBy, boostedBy); - eventMap.insert(MastodonPostsModel::RepliesCount, repliesCount); - eventMap.insert(MastodonPostsModel::FavouritesCount, favouritesCount); - eventMap.insert(MastodonPostsModel::ReblogsCount, reblogsCount); - eventMap.insert(MastodonPostsModel::Favourited, favourited); - eventMap.insert(MastodonPostsModel::Reblogged, reblogged); - eventMap.insert(MastodonPostsModel::InstanceUrl, m_database.instanceUrl(post)); - - QVariantList images; - Q_FOREACH (const SocialPostImage::ConstPtr &image, post->images()) { - images.append(createImageData(image)); - } - eventMap.insert(MastodonPostsModel::Images, images); - - QVariantList accountsVariant; - Q_FOREACH (int account, post->accounts()) { - accountsVariant.append(account); - } - eventMap.insert(MastodonPostsModel::Accounts, accountsVariant); - data.append(eventMap); - } - - const int oldCount = m_data.count(); - beginResetModel(); - m_data = data; - endResetModel(); - if (oldCount != m_data.count()) { - emit countChanged(); - } -} diff --git a/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.h b/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.h deleted file mode 100644 index e30437d..0000000 --- a/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.h +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (C) 2013-2026 Jolla Ltd. - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This 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 library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -#ifndef MASTODONPOSTSMODEL_H -#define MASTODONPOSTSMODEL_H - -#include "mastodonpostsdatabase.h" -#include -#include - -class MastodonPostsModel: public QAbstractListModel -{ - Q_OBJECT - Q_PROPERTY(QVariantList accountIdFilter READ accountIdFilter WRITE setAccountIdFilter NOTIFY accountIdFilterChanged) - Q_PROPERTY(int count READ rowCount NOTIFY countChanged) - -public: - enum MastodonPostsRole { - MastodonId = 0, - Name, - AccountName, - Acct, - Body, - Timestamp, - Icon, - Images, - Url, - Link, - BoostedBy, - RebloggedBy, - RepliesCount, - FavouritesCount, - ReblogsCount, - Favourited, - Reblogged, - InstanceUrl, - Accounts - }; - - explicit MastodonPostsModel(QObject *parent = 0); - - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - QVariant data(const QModelIndex &index, int role) const override; - QHash roleNames() const override; - - QVariantList accountIdFilter() const; - void setAccountIdFilter(const QVariantList &accountIds); - - Q_INVOKABLE void refresh(); - -signals: - void accountIdFilterChanged(); - void countChanged(); - -private slots: - void postsChanged(); - -private: - typedef QMap RowData; - QList m_data; - MastodonPostsDatabase m_database; -}; - -#endif // MASTODONPOSTSMODEL_H diff --git a/eventsview-plugins/eventsview-plugin-mastodon/plugin.cpp b/eventsview-plugins/eventsview-plugin-mastodon/plugin.cpp deleted file mode 100644 index 9ade6e2..0000000 --- a/eventsview-plugins/eventsview-plugin-mastodon/plugin.cpp +++ /dev/null @@ -1,27 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -#include -#include - -#include "mastodonpostactions.h" -#include "mastodonpostsmodel.h" - -class JollaEventsviewMastodonPlugin : public QQmlExtensionPlugin -{ - Q_OBJECT - Q_PLUGIN_METADATA(IID "com.jolla.eventsview.mastodon") - -public: - void registerTypes(const char *uri) override - { - Q_ASSERT(QLatin1String(uri) == QLatin1String("com.jolla.eventsview.mastodon")); - qmlRegisterType(uri, 1, 0, "MastodonPostsModel"); - qmlRegisterType(uri, 1, 0, "MastodonPostActions"); - } -}; - -#include "plugin.moc" diff --git a/eventsview-plugins/eventsview-plugin-mastodon/qmldir b/eventsview-plugins/eventsview-plugin-mastodon/qmldir deleted file mode 100644 index 74461ce..0000000 --- a/eventsview-plugins/eventsview-plugin-mastodon/qmldir +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright (C) 2013-2026 Jolla Ltd. - -module com.jolla.eventsview.mastodon -plugin jollaeventsviewmastodonplugin diff --git a/eventsview-plugins/eventsview-plugins.pro b/eventsview-plugins/eventsview-plugins.pro index 095fd02..bc96c5f 100644 --- a/eventsview-plugins/eventsview-plugins.pro +++ b/eventsview-plugins/eventsview-plugins.pro @@ -3,4 +3,4 @@ # SPDX-License-Identifier: BSD-3-Clause TEMPLATE = subdirs -SUBDIRS += eventsview-plugin-mastodon +SUBDIRS += eventsview-plugin-fediverse diff --git a/icons/svgs/icons/icon-l-fediverse.svg b/icons/svgs/icons/icon-l-fediverse.svg new file mode 100644 index 0000000..3ff5f76 --- /dev/null +++ b/icons/svgs/icons/icon-l-fediverse.svg @@ -0,0 +1,10 @@ + + + + diff --git a/icons/svgs/icons/icon-l-mastodon.svg b/icons/svgs/icons/icon-l-mastodon.svg deleted file mode 100644 index 0f8baeb..0000000 --- a/icons/svgs/icons/icon-l-mastodon.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/rpm/sailfish-account-fediverse.spec b/rpm/sailfish-account-fediverse.spec new file mode 100644 index 0000000..558ff50 --- /dev/null +++ b/rpm/sailfish-account-fediverse.spec @@ -0,0 +1,133 @@ +# SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. +# +# SPDX-License-Identifier: BSD-3-Clause + +Name: sailfish-account-fediverse +License: BSD-3-Clause AND LGPL-2.1-or-later +Version: 1.0.2 +Release: 1 +Source0: %{name}-%{version}.tar.bz2 +Summary: SailfishOS account plugin for Fediverse +BuildRequires: qt5-qmake +BuildRequires: qt5-qttools-linguist +BuildRequires: sailfish-svg2png +BuildRequires: pkgconfig(Qt5Core) +BuildRequires: pkgconfig(Qt5DBus) +BuildRequires: pkgconfig(Qt5Sql) +BuildRequires: pkgconfig(Qt5Network) +BuildRequires: pkgconfig(Qt5Qml) +BuildRequires: pkgconfig(mlite5) +BuildRequires: pkgconfig(buteosyncfw5) >= 0.10.0 +BuildRequires: pkgconfig(accounts-qt5) +BuildRequires: pkgconfig(libsignon-qt5) +BuildRequires: pkgconfig(socialcache) +BuildRequires: pkgconfig(libsailfishkeyprovider) +BuildRequires: pkgconfig(sailfishaccounts) +BuildRequires: pkgconfig(nemotransferengine-qt5) >= 2.0.0 +BuildRequires: pkgconfig(nemonotifications-qt5) +Requires: jolla-settings-accounts-extensions-onlinesync +Requires: qmf-oauth2-plugin >= 0.0.7 +Requires: buteo-syncfw-qt5-msyncd +Requires: systemd +Requires: lipstick-jolla-home-qt5-components >= 1.2.50 +Requires: eventsview-extensions +Requires: sailfishsilica-qt5 >= 1.1.108 +Requires: declarative-transferengine-qt5 >= 0.3.13 +Requires: nemo-transferengine-qt5 >= 2.0.0 +Requires(post): %{_libexecdir}/manage-groups +Requires(postun): %{_libexecdir}/manage-groups + +%description +%{summary}. Supports displaying current feed in the Events View, +sharing images, and notifications. + +%package -n sailfish-account-fediverse-ts-devel +Summary: Translation source files for sailfish-account-fediverse +Requires: %{name} = %{version}-%{release} + +%description -n sailfish-account-fediverse-ts-devel +Translation source files for sailfish-account-fediverse components. + +%prep +%setup -q -n %{name}-%{version} + +%build +%qmake5 "VERSION=%{version}" +%make_build + +%install +%qmake5_install + +%post +/sbin/ldconfig +%{_libexecdir}/manage-groups add account-fediverse || : +systemctl-user try-restart msyncd.service || : + +%posttrans +# Pre-4.6 SailfishOS resolves theme icons from the legacy meegotouch tree. +# If that theme exists, point it at the packaged silica icons. +theme_dir=%{_datadir}/themes/sailfish-default +legacy_dir="$theme_dir/meegotouch" +if [ -d "$legacy_dir" ]; then + for icon in "$theme_dir"/silica/*/icons/icon-l-fediverse.png; do + [ -e "$icon" ] || continue + scale="$(basename "$(dirname "$(dirname "$icon")")")" + target_dir="$legacy_dir/$scale/icons" + [ -d "$target_dir" ] || continue + ln -sfn "../../../silica/${scale}/icons/icon-l-fediverse.png" \ + "$target_dir/icon-l-fediverse.png" + done +fi + +%postun +/sbin/ldconfig +if [ "$1" -eq 0 ]; then + theme_dir=%{_datadir}/themes/sailfish-default + legacy_dir="$theme_dir/meegotouch" + if [ -d "$legacy_dir" ]; then + for icon in "$legacy_dir"/*/icons/icon-l-fediverse.png; do + [ -L "$icon" ] || continue + rm -f "$icon" + done + fi + %{_libexecdir}/manage-groups remove account-fediverse || : +fi + +%files +%license LICENSES/BSD-3-Clause.txt +%license LICENSES/LGPL-2.1-or-later.txt +%{_libdir}/libfediversecommon.so.* +%exclude %{_libdir}/libfediversecommon.so +%{_libdir}/libfediversebuteocommon.so.* +%exclude %{_libdir}/libfediversebuteocommon.so +%{_datadir}/accounts/providers/fediverse.provider +%{_datadir}/accounts/services/fediverse-microblog.service +%{_datadir}/accounts/services/fediverse-notifications.service +%{_datadir}/accounts/services/fediverse-sharing.service +%{_datadir}/accounts/ui/FediverseSettingsDisplay.qml +%{_datadir}/accounts/ui/fediverse.qml +%{_datadir}/accounts/ui/fediverse-settings.qml +%{_datadir}/accounts/ui/fediverse-update.qml +%{_libdir}/qt5/qml/com/jolla/settings/accounts/fediverse/* +%{_datadir}/translations/settings-accounts-fediverse_eng_en.qm +%{_datadir}/themes/sailfish-default/silica/*/icons/icon-l-fediverse.png +%{_libdir}/buteo-plugins-qt5/oopp/libfediverse-posts-client.so +%config %{_sysconfdir}/buteo/profiles/client/fediverse-posts.xml +%config %{_sysconfdir}/buteo/profiles/sync/fediverse.Posts.xml +%{_libdir}/buteo-plugins-qt5/oopp/libfediverse-notifications-client.so +%config %{_sysconfdir}/buteo/profiles/client/fediverse-notifications.xml +%config %{_sysconfdir}/buteo/profiles/sync/fediverse.Notifications.xml +%{_libdir}/qt5/qml/com/jolla/eventsview/fediverse/* +%{_datadir}/lipstick/eventfeed/fediverse-delegate.qml +%{_datadir}/lipstick/eventfeed/FediverseFeedItem.qml +%{_datadir}/translations/lipstick-jolla-home-fediverse_eng_en.qm +%{_datadir}/translations/lipstick-jolla-home-fediverse-notifications_eng_en.qm + +%{_libdir}/nemo-transferengine/plugins/sharing/libfediverseshareplugin.so +%{_libdir}/nemo-transferengine/plugins/transfer/libfediversetransferplugin.so +%{_datadir}/nemo-transferengine/plugins/sharing/FediverseSharePost.qml + +%files -n sailfish-account-fediverse-ts-devel +%{_datadir}/translations/source/settings-accounts-fediverse.ts +%{_datadir}/translations/source/lipstick-jolla-home-fediverse.ts +%{_datadir}/translations/source/lipstick-jolla-home-fediverse-notifications.ts diff --git a/rpm/sailfish-account-mastodon.spec b/rpm/sailfish-account-mastodon.spec deleted file mode 100644 index 923ae8c..0000000 --- a/rpm/sailfish-account-mastodon.spec +++ /dev/null @@ -1,133 +0,0 @@ -# SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. -# -# SPDX-License-Identifier: BSD-3-Clause - -Name: sailfish-account-mastodon -License: BSD-3-Clause AND LGPL-2.1-or-later -Version: 1.0.2 -Release: 1 -Source0: %{name}-%{version}.tar.bz2 -Summary: SailfishOS account plugin for Mastodon -BuildRequires: qt5-qmake -BuildRequires: qt5-qttools-linguist -BuildRequires: sailfish-svg2png -BuildRequires: pkgconfig(Qt5Core) -BuildRequires: pkgconfig(Qt5DBus) -BuildRequires: pkgconfig(Qt5Sql) -BuildRequires: pkgconfig(Qt5Network) -BuildRequires: pkgconfig(Qt5Qml) -BuildRequires: pkgconfig(mlite5) -BuildRequires: pkgconfig(buteosyncfw5) >= 0.10.0 -BuildRequires: pkgconfig(accounts-qt5) -BuildRequires: pkgconfig(libsignon-qt5) -BuildRequires: pkgconfig(socialcache) -BuildRequires: pkgconfig(libsailfishkeyprovider) -BuildRequires: pkgconfig(sailfishaccounts) -BuildRequires: pkgconfig(nemotransferengine-qt5) >= 2.0.0 -BuildRequires: pkgconfig(nemonotifications-qt5) -Requires: jolla-settings-accounts-extensions-onlinesync -Requires: qmf-oauth2-plugin >= 0.0.7 -Requires: buteo-syncfw-qt5-msyncd -Requires: systemd -Requires: lipstick-jolla-home-qt5-components >= 1.2.50 -Requires: eventsview-extensions -Requires: sailfishsilica-qt5 >= 1.1.108 -Requires: declarative-transferengine-qt5 >= 0.3.13 -Requires: nemo-transferengine-qt5 >= 2.0.0 -Requires(post): %{_libexecdir}/manage-groups -Requires(postun): %{_libexecdir}/manage-groups - -%description -%{summary}. Supports displaying current feed in the Events View, -sharing images, and notifications. - -%package -n sailfish-account-mastodon-ts-devel -Summary: Translation source files for sailfish-account-mastodon -Requires: %{name} = %{version}-%{release} - -%description -n sailfish-account-mastodon-ts-devel -Translation source files for sailfish-account-mastodon components. - -%prep -%setup -q -n %{name}-%{version} - -%build -%qmake5 "VERSION=%{version}" -%make_build - -%install -%qmake5_install - -%post -/sbin/ldconfig -%{_libexecdir}/manage-groups add account-mastodon || : -systemctl-user try-restart msyncd.service || : - -%posttrans -# Pre-4.6 SailfishOS resolves theme icons from the legacy meegotouch tree. -# If that theme exists, point it at the packaged silica icons. -theme_dir=%{_datadir}/themes/sailfish-default -legacy_dir="$theme_dir/meegotouch" -if [ -d "$legacy_dir" ]; then - for icon in "$theme_dir"/silica/*/icons/icon-l-mastodon.png; do - [ -e "$icon" ] || continue - scale="$(basename "$(dirname "$(dirname "$icon")")")" - target_dir="$legacy_dir/$scale/icons" - [ -d "$target_dir" ] || continue - ln -sfn "../../../silica/${scale}/icons/icon-l-mastodon.png" \ - "$target_dir/icon-l-mastodon.png" - done -fi - -%postun -/sbin/ldconfig -if [ "$1" -eq 0 ]; then - theme_dir=%{_datadir}/themes/sailfish-default - legacy_dir="$theme_dir/meegotouch" - if [ -d "$legacy_dir" ]; then - for icon in "$legacy_dir"/*/icons/icon-l-mastodon.png; do - [ -L "$icon" ] || continue - rm -f "$icon" - done - fi - %{_libexecdir}/manage-groups remove account-mastodon || : -fi - -%files -%license LICENSES/BSD-3-Clause.txt -%license LICENSES/LGPL-2.1-or-later.txt -%{_libdir}/libmastodoncommon.so.* -%exclude %{_libdir}/libmastodoncommon.so -%{_libdir}/libmastodonbuteocommon.so.* -%exclude %{_libdir}/libmastodonbuteocommon.so -%{_datadir}/accounts/providers/mastodon.provider -%{_datadir}/accounts/services/mastodon-microblog.service -%{_datadir}/accounts/services/mastodon-notifications.service -%{_datadir}/accounts/services/mastodon-sharing.service -%{_datadir}/accounts/ui/MastodonSettingsDisplay.qml -%{_datadir}/accounts/ui/mastodon.qml -%{_datadir}/accounts/ui/mastodon-settings.qml -%{_datadir}/accounts/ui/mastodon-update.qml -%{_libdir}/qt5/qml/com/jolla/settings/accounts/mastodon/* -%{_datadir}/translations/settings-accounts-mastodon_eng_en.qm -%{_datadir}/themes/sailfish-default/silica/*/icons/icon-l-mastodon.png -%{_libdir}/buteo-plugins-qt5/oopp/libmastodon-posts-client.so -%config %{_sysconfdir}/buteo/profiles/client/mastodon-posts.xml -%config %{_sysconfdir}/buteo/profiles/sync/mastodon.Posts.xml -%{_libdir}/buteo-plugins-qt5/oopp/libmastodon-notifications-client.so -%config %{_sysconfdir}/buteo/profiles/client/mastodon-notifications.xml -%config %{_sysconfdir}/buteo/profiles/sync/mastodon.Notifications.xml -%{_libdir}/qt5/qml/com/jolla/eventsview/mastodon/* -%{_datadir}/lipstick/eventfeed/mastodon-delegate.qml -%{_datadir}/lipstick/eventfeed/MastodonFeedItem.qml -%{_datadir}/translations/lipstick-jolla-home-mastodon_eng_en.qm -%{_datadir}/translations/lipstick-jolla-home-mastodon-notifications_eng_en.qm - -%{_libdir}/nemo-transferengine/plugins/sharing/libmastodonshareplugin.so -%{_libdir}/nemo-transferengine/plugins/transfer/libmastodontransferplugin.so -%{_datadir}/nemo-transferengine/plugins/sharing/MastodonSharePost.qml - -%files -n sailfish-account-mastodon-ts-devel -%{_datadir}/translations/source/settings-accounts-mastodon.ts -%{_datadir}/translations/source/lipstick-jolla-home-mastodon.ts -%{_datadir}/translations/source/lipstick-jolla-home-mastodon-notifications.ts diff --git a/sailfish-account-fediverse.pro b/sailfish-account-fediverse.pro new file mode 100644 index 0000000..7be0bff --- /dev/null +++ b/sailfish-account-fediverse.pro @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. +# +# SPDX-License-Identifier: BSD-3-Clause + +TEMPLATE = subdirs +SUBDIRS += \ + common \ + settings \ + transferengine-plugins \ + buteo-plugins \ + eventsview-plugins \ + icons + +buteo-plugins.depends = common +transferengine-plugins.depends = common +eventsview-plugins.depends = common + +OTHER_FILES += rpm/sailfish-account-fediverse.spec diff --git a/sailfish-account-mastodon.pro b/sailfish-account-mastodon.pro deleted file mode 100644 index 91befc3..0000000 --- a/sailfish-account-mastodon.pro +++ /dev/null @@ -1,18 +0,0 @@ -# SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. -# -# SPDX-License-Identifier: BSD-3-Clause - -TEMPLATE = subdirs -SUBDIRS += \ - common \ - settings \ - transferengine-plugins \ - buteo-plugins \ - eventsview-plugins \ - icons - -buteo-plugins.depends = common -transferengine-plugins.depends = common -eventsview-plugins.depends = common - -OTHER_FILES += rpm/sailfish-account-mastodon.spec diff --git a/settings/accounts-translations-plugin/accounts-translations-plugin.pro b/settings/accounts-translations-plugin/accounts-translations-plugin.pro index aad978f..faa5ae5 100644 --- a/settings/accounts-translations-plugin/accounts-translations-plugin.pro +++ b/settings/accounts-translations-plugin/accounts-translations-plugin.pro @@ -3,13 +3,13 @@ # SPDX-License-Identifier: BSD-3-Clause TEMPLATE = lib -TARGET = mastodonaccountstranslationsplugin +TARGET = fediverseaccountstranslationsplugin TARGET = $$qtLibraryTarget($$TARGET) -MODULENAME = com/jolla/settings/accounts/mastodon +MODULENAME = com/jolla/settings/accounts/fediverse TARGETPATH = $$[QT_INSTALL_QML]/$$MODULENAME -QT += qml +QT += qml network CONFIG += plugin SOURCES += plugin.cpp diff --git a/settings/accounts-translations-plugin/plugin.cpp b/settings/accounts-translations-plugin/plugin.cpp index 4a1c651..e377b6c 100644 --- a/settings/accounts-translations-plugin/plugin.cpp +++ b/settings/accounts-translations-plugin/plugin.cpp @@ -5,10 +5,20 @@ */ #include +#include +#include +#include +#include #include +#include +#include #include #include +#include +#include +#include #include +#include #include class AppTranslator : public QTranslator @@ -27,10 +37,167 @@ public: } }; -class MastodonAccountsTranslationsPlugin : public QQmlExtensionPlugin +class FediverseInstanceIconCache : public QObject { Q_OBJECT - Q_PLUGIN_METADATA(IID "com.jolla.settings.accounts.mastodon") +public: + explicit FediverseInstanceIconCache(QObject *parent = nullptr) + : QObject(parent) + , m_networkAccessManager(new QNetworkAccessManager(this)) + { + } + + Q_INVOKABLE QString cachedIconPath(const QString &apiHost) const + { + const QString normalizedHost = normalizeApiHost(apiHost); + if (normalizedHost.isEmpty()) { + return QString(); + } + + const QString prefix = iconPrefix(normalizedHost); + const QDir dir(cacheDirectory()); + const QStringList matches = dir.entryList(QStringList() << (prefix + QStringLiteral(".*")), QDir::Files); + if (matches.isEmpty()) { + return QString(); + } + + return dir.absoluteFilePath(matches.first()); + } + + Q_INVOKABLE void cacheIcon(const QString &apiHost, const QString &iconUrl) + { + const QString normalizedHost = normalizeApiHost(apiHost); + const QUrl normalizedUrl = QUrl::fromUserInput(iconUrl); + if (normalizedHost.isEmpty() || !normalizedUrl.isValid() || normalizedUrl.scheme().isEmpty()) { + emit iconError(apiHost); + return; + } + + const QString existingPath = cachedIconPath(normalizedHost); + if (!existingPath.isEmpty()) { + emit iconReady(normalizedHost, existingPath); + return; + } + + if (m_hostsInFlight.contains(normalizedHost)) { + return; + } + + QNetworkReply *reply = m_networkAccessManager->get(QNetworkRequest(normalizedUrl)); + reply->setProperty("apiHost", normalizedHost); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { handleReply(reply); }); + m_hostsInFlight.insert(normalizedHost); + } + +Q_SIGNALS: + void iconReady(const QString &apiHost, const QString &iconPath); + void iconError(const QString &apiHost); + +private: + static QString normalizeApiHost(const QString &rawHost) + { + QString host = rawHost.trimmed(); + if (host.isEmpty()) { + return QString(); + } + if (!host.startsWith(QLatin1String("https://")) && !host.startsWith(QLatin1String("http://"))) { + host.prepend(QStringLiteral("https://")); + } + + const QUrl url(host); + if (!url.isValid() || url.host().isEmpty()) { + return QString(); + } + + QString normalized = QString::fromLatin1(url.toEncoded(QUrl::RemovePath | QUrl::RemoveQuery | QUrl::RemoveFragment)); + if (normalized.endsWith(QLatin1Char('/'))) { + normalized.chop(1); + } + return normalized; + } + + static QString cacheDirectory() + { + const QString base = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation); + return base + QStringLiteral("/sailfish-account-fediverse/icons"); + } + + static QString iconPrefix(const QString &apiHost) + { + return QString::fromLatin1(QCryptographicHash::hash(apiHost.toUtf8(), QCryptographicHash::Sha1).toHex()); + } + + static QString extensionForReply(QNetworkReply *reply) + { + const QString suffix = QFileInfo(reply->url().path()).suffix().trimmed().toLower(); + if (!suffix.isEmpty()) { + return suffix; + } + + const QString contentType = QString::fromLatin1(reply->header(QNetworkRequest::ContentTypeHeader).toByteArray()).toLower(); + if (contentType.contains(QLatin1String("png"))) { + return QStringLiteral("png"); + } + if (contentType.contains(QLatin1String("jpeg")) || contentType.contains(QLatin1String("jpg"))) { + return QStringLiteral("jpg"); + } + if (contentType.contains(QLatin1String("webp"))) { + return QStringLiteral("webp"); + } + if (contentType.contains(QLatin1String("svg"))) { + return QStringLiteral("svg"); + } + return QStringLiteral("img"); + } + + void handleReply(QNetworkReply *reply) + { + const QString apiHost = reply->property("apiHost").toString(); + m_hostsInFlight.remove(apiHost); + + const int httpCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const bool success = reply->error() == QNetworkReply::NoError && httpCode >= 200 && httpCode < 300; + const QString extension = extensionForReply(reply); + const QByteArray data = reply->readAll(); + reply->deleteLater(); + + if (!success || data.isEmpty()) { + emit iconError(apiHost); + return; + } + + const QString dirPath = cacheDirectory(); + QDir dir; + if (!dir.mkpath(dirPath)) { + emit iconError(apiHost); + return; + } + + const QString prefix = iconPrefix(apiHost); + const QDir cacheDir(dirPath); + const QStringList matches = cacheDir.entryList(QStringList() << (prefix + QStringLiteral(".*")), QDir::Files); + for (const QString &match : matches) { + QFile::remove(cacheDir.absoluteFilePath(match)); + } + + const QString filePath = cacheDir.absoluteFilePath(prefix + QStringLiteral(".") + extension); + QSaveFile file(filePath); + if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit()) { + emit iconError(apiHost); + return; + } + + emit iconReady(apiHost, filePath); + } + + QNetworkAccessManager *m_networkAccessManager; + QSet m_hostsInFlight; +}; + +class FediverseAccountsTranslationsPlugin : public QQmlExtensionPlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "com.jolla.settings.accounts.fediverse") public: void initializeEngine(QQmlEngine *engine, const char *uri) override @@ -38,18 +205,19 @@ public: Q_UNUSED(uri) AppTranslator *engineeringEnglish = new AppTranslator(engine); - engineeringEnglish->load("settings-accounts-mastodon_eng_en", "/usr/share/translations"); + engineeringEnglish->load("settings-accounts-fediverse_eng_en", "/usr/share/translations"); AppTranslator *translator = new AppTranslator(engine); - translator->load(QLocale(), "settings-accounts-mastodon", "-", "/usr/share/translations"); + translator->load(QLocale(), "settings-accounts-fediverse", "-", "/usr/share/translations"); } void registerTypes(const char *uri) override { - Q_ASSERT(QLatin1String(uri) == QLatin1String("com.jolla.settings.accounts.mastodon")); - qmlRegisterUncreatableType(uri, 1, 0, - "MastodonTranslationPlugin", + Q_ASSERT(QLatin1String(uri) == QLatin1String("com.jolla.settings.accounts.fediverse")); + qmlRegisterUncreatableType(uri, 1, 0, + "FediverseTranslationPlugin", QString()); + qmlRegisterType(uri, 1, 0, "FediverseInstanceIconCache"); } }; diff --git a/settings/accounts-translations-plugin/qmldir b/settings/accounts-translations-plugin/qmldir index cf96622..69f186b 100644 --- a/settings/accounts-translations-plugin/qmldir +++ b/settings/accounts-translations-plugin/qmldir @@ -1,4 +1,4 @@ # Copyright (C) 2013-2026 Jolla Ltd. -module com.jolla.settings.accounts.mastodon -plugin mastodonaccountstranslationsplugin +module com.jolla.settings.accounts.fediverse +plugin fediverseaccountstranslationsplugin diff --git a/settings/accounts/accounts.pro b/settings/accounts/accounts.pro index 37982a3..1c80cf9 100644 --- a/settings/accounts/accounts.pro +++ b/settings/accounts/accounts.pro @@ -4,8 +4,8 @@ TEMPLATE = aux -TS_FILE = $$OUT_PWD/settings-accounts-mastodon.ts -EE_QM = $$OUT_PWD/settings-accounts-mastodon_eng_en.qm +TS_FILE = $$OUT_PWD/settings-accounts-fediverse.ts +EE_QM = $$OUT_PWD/settings-accounts-fediverse_eng_en.qm ts.commands += lupdate $$PWD/ui -ts $$TS_FILE ts.CONFIG += no_check_exist no_link @@ -30,29 +30,29 @@ QMAKE_EXTRA_TARGETS += ts engineering_english PRE_TARGETDEPS += ts engineering_english OTHER_FILES += \ - $$PWD/providers/mastodon.provider \ - $$PWD/services/mastodon-microblog.service \ - $$PWD/services/mastodon-notifications.service \ - $$PWD/services/mastodon-sharing.service \ - $$PWD/ui/MastodonSettingsDisplay.qml \ - $$PWD/ui/mastodon.qml \ - $$PWD/ui/mastodon-settings.qml \ - $$PWD/ui/mastodon-update.qml - -provider.files += $$PWD/providers/mastodon.provider + $$PWD/providers/fediverse.provider \ + $$PWD/services/fediverse-microblog.service \ + $$PWD/services/fediverse-notifications.service \ + $$PWD/services/fediverse-sharing.service \ + $$PWD/ui/FediverseSettingsDisplay.qml \ + $$PWD/ui/fediverse.qml \ + $$PWD/ui/fediverse-settings.qml \ + $$PWD/ui/fediverse-update.qml + +provider.files += $$PWD/providers/fediverse.provider provider.path = /usr/share/accounts/providers/ services.files += \ - $$PWD/services/mastodon-microblog.service \ - $$PWD/services/mastodon-notifications.service \ - $$PWD/services/mastodon-sharing.service + $$PWD/services/fediverse-microblog.service \ + $$PWD/services/fediverse-notifications.service \ + $$PWD/services/fediverse-sharing.service services.path = /usr/share/accounts/services/ ui.files += \ - $$PWD/ui/MastodonSettingsDisplay.qml \ - $$PWD/ui/mastodon.qml \ - $$PWD/ui/mastodon-settings.qml \ - $$PWD/ui/mastodon-update.qml + $$PWD/ui/FediverseSettingsDisplay.qml \ + $$PWD/ui/fediverse.qml \ + $$PWD/ui/fediverse-settings.qml \ + $$PWD/ui/fediverse-update.qml ui.path = /usr/share/accounts/ui/ INSTALLS += provider services ui ts_install engineering_english_install diff --git a/settings/accounts/providers/fediverse.provider b/settings/accounts/providers/fediverse.provider new file mode 100644 index 0000000..bb6e329 --- /dev/null +++ b/settings/accounts/providers/fediverse.provider @@ -0,0 +1,35 @@ + + + + + + /usr/share/translations/settings-accounts-fediverse + Fediverse + Fediverse social network + image://theme/icon-l-fediverse + + + + + user-group:account-fediverse + + diff --git a/settings/accounts/providers/mastodon.provider b/settings/accounts/providers/mastodon.provider deleted file mode 100644 index 422c231..0000000 --- a/settings/accounts/providers/mastodon.provider +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - /usr/share/translations/settings-accounts-mastodon - Mastodon - Mastodon social network - image://theme/icon-l-mastodon - - - - - user-group:account-mastodon - - diff --git a/settings/accounts/services/fediverse-microblog.service b/settings/accounts/services/fediverse-microblog.service new file mode 100644 index 0000000..9070ec1 --- /dev/null +++ b/settings/accounts/services/fediverse-microblog.service @@ -0,0 +1,32 @@ + + + + + microblogging + /usr/share/translations/settings-accounts-fediverse + Posts + image://theme/icon-l-fediverse + fediverse + + + diff --git a/settings/accounts/services/fediverse-notifications.service b/settings/accounts/services/fediverse-notifications.service new file mode 100644 index 0000000..ea1b59b --- /dev/null +++ b/settings/accounts/services/fediverse-notifications.service @@ -0,0 +1,32 @@ + + + + + microblogging + /usr/share/translations/settings-accounts-fediverse + Notifications + image://theme/icon-l-fediverse + fediverse + + + diff --git a/settings/accounts/services/fediverse-sharing.service b/settings/accounts/services/fediverse-sharing.service new file mode 100644 index 0000000..f66f11b --- /dev/null +++ b/settings/accounts/services/fediverse-sharing.service @@ -0,0 +1,31 @@ + + + + + sharing + /usr/share/translations/settings-accounts-fediverse + Sharing + image://theme/icon-l-fediverse + fediverse + + + diff --git a/settings/accounts/services/mastodon-microblog.service b/settings/accounts/services/mastodon-microblog.service deleted file mode 100644 index 527f70f..0000000 --- a/settings/accounts/services/mastodon-microblog.service +++ /dev/null @@ -1,32 +0,0 @@ - - - - - microblogging - /usr/share/translations/settings-accounts-mastodon - Posts - image://theme/icon-l-mastodon - mastodon - - - diff --git a/settings/accounts/services/mastodon-notifications.service b/settings/accounts/services/mastodon-notifications.service deleted file mode 100644 index f5471c9..0000000 --- a/settings/accounts/services/mastodon-notifications.service +++ /dev/null @@ -1,32 +0,0 @@ - - - - - microblogging - /usr/share/translations/settings-accounts-mastodon - Notifications - image://theme/icon-l-mastodon - mastodon - - - diff --git a/settings/accounts/services/mastodon-sharing.service b/settings/accounts/services/mastodon-sharing.service deleted file mode 100644 index fdf342a..0000000 --- a/settings/accounts/services/mastodon-sharing.service +++ /dev/null @@ -1,31 +0,0 @@ - - - - - sharing - /usr/share/translations/settings-accounts-mastodon - Sharing - image://theme/icon-l-mastodon - mastodon - - - diff --git a/settings/accounts/ui/FediverseSettingsDisplay.qml b/settings/accounts/ui/FediverseSettingsDisplay.qml new file mode 100644 index 0000000..7494460 --- /dev/null +++ b/settings/accounts/ui/FediverseSettingsDisplay.qml @@ -0,0 +1,181 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import Sailfish.Accounts 1.0 +import com.jolla.settings.accounts 1.0 +import com.jolla.settings.accounts.fediverse 1.0 +import org.nemomobile.configuration 1.0 + +StandardAccountSettingsDisplay { + id: root + + settingsModified: true + property bool postsServiceEnabled: false + property string instanceTitle: { + var value = root.account.configurationValues("")["instance/Title"] + return value ? value.toString().trim() : "" + } + + function refreshDescriptionEditor() { + var description = root.account.configurationValues("")["description"] + var descriptionValue = description ? description.toString().trim() : "" + var credentialsUserName = root.account.defaultCredentialsUserName + ? root.account.defaultCredentialsUserName.toString().trim() + : "" + if (descriptionValue.length > 0 && credentialsUserName !== descriptionValue) { + root.account.setConfigurationValue("", "default_credentials_username", descriptionValue) + } + + // Reuse the standard "Description" field as the account handle editor. + if (descriptionValue.length > 0) { + root.account.displayName = descriptionValue + } else if (credentialsUserName.length > 0) { + root.account.displayName = credentialsUserName + } else { + root.account.displayName = "" + } + } + + function _providerDisplayName() { + if (instanceTitle.length > 0) { + return instanceTitle + } + + var providerDisplayName = root.accountProvider && root.accountProvider.displayName + ? root.accountProvider.displayName.toString().trim() + : "" + return providerDisplayName.length > 0 ? providerDisplayName : qsTrId("settings-accounts-fediverse-la-provider_name") + } + + onAboutToSaveAccount: { + settingsLoader.updateAllSyncProfiles() + + var storedDescriptionValue = root.account.configurationValues("")["description"] + var storedDescription = storedDescriptionValue ? storedDescriptionValue.toString().trim() : "" + var storedCredentialsUserName = root.account.defaultCredentialsUserName + ? root.account.defaultCredentialsUserName.toString().trim() + : "" + var editedDescription = root.account.displayName + ? root.account.displayName.toString().trim() + : "" + var providerDisplayName = _providerDisplayName() + if (editedDescription === providerDisplayName) { + // Avoid clobbering stored handle if displayName temporarily reverts to provider name. + editedDescription = storedDescription.length > 0 ? storedDescription : storedCredentialsUserName + } + + if (storedDescription !== editedDescription) { + root.account.setConfigurationValue("", "description", editedDescription) + } + + if (storedCredentialsUserName !== editedDescription) { + root.account.setConfigurationValue("", "default_credentials_username", editedDescription) + } + + // Keep account list title fixed to the discovered instance title. + root.account.displayName = providerDisplayName + + if (eventsSyncSwitch.checked !== root.account.configurationValues("")["FeedViewAutoSync"]) { + root.account.setConfigurationValue("", "FeedViewAutoSync", eventsSyncSwitch.checked) + } + } + + StandardAccountSettingsLoader { + id: settingsLoader + account: root.account + accountProvider: root.accountProvider + accountManager: root.accountManager + autoEnableServices: root.autoEnableAccount + + onSettingsLoaded: { + syncServicesRepeater.model = syncServices + otherServicesDisplay.serviceModel = otherServices + + refreshDescriptionEditor() + + var autoSync = root.account.configurationValues("")["FeedViewAutoSync"] + var isNewAccount = root.autoEnableAccount + eventsSyncSwitch.checked = (isNewAccount || autoSync === true) + } + } + + Column { + id: syncServicesDisplay + width: parent.width + + SectionHeader { + //: Options for data to be downloaded from a remote server + //% "Download" + text: qsTrId("settings-accounts-la-download_options") + } + + Repeater { + id: syncServicesRepeater + TextSwitch { + checked: model.enabled + text: model.serviceName === "fediverse-microblog" + //% "Posts" + ? qsTrId("settings-accounts-fediverse-la-service_posts") + : (model.serviceName === "fediverse-notifications" + //% "Notifications" + ? qsTrId("settings-accounts-fediverse-la-service_notifications") + : model.displayName) + description: model.serviceName === "fediverse-microblog" + //% "Show Fediverse posts in the Events view." + ? qsTrId("settings-accounts-fediverse-la-service_posts_description") + : (model.serviceName === "fediverse-notifications" + //% "Show Fediverse notifications." + ? qsTrId("settings-accounts-fediverse-la-service_notifications_description") + : "") + visible: text.length > 0 + onCheckedChanged: { + if (model.serviceName === "fediverse-microblog") { + root.postsServiceEnabled = checked + } + if (checked) { + root.account.enableWithService(model.serviceName) + } else { + root.account.disableWithService(model.serviceName) + } + } + } + } + + TextSwitch { + id: eventsSyncSwitch + + //% "Sync Fediverse feed automatically" + text: qsTrId("settings-accounts-fediverse-la-auto_sync_feed") + //% "Fetch new posts periodically when browsing Events Fediverse feed." + description: qsTrId("settings-accounts-fediverse-la-auto_sync_feed_description") + enabled: root.postsServiceEnabled + + onCheckedChanged: { + autoSyncConf.value = checked + } + } + } + + ConfigurationValue { + id: autoSyncConf + key: "/desktop/lipstick-jolla-home/events/auto_sync_feeds/" + root.account.identifier + } + + AccountServiceSettingsDisplay { + id: otherServicesDisplay + enabled: root.accountEnabled + + onUpdateServiceEnabledStatus: { + if (enabled) { + root.account.enableWithService(serviceName) + } else { + root.account.disableWithService(serviceName) + } + } + } +} diff --git a/settings/accounts/ui/MastodonSettingsDisplay.qml b/settings/accounts/ui/MastodonSettingsDisplay.qml deleted file mode 100644 index 13ac06c..0000000 --- a/settings/accounts/ui/MastodonSettingsDisplay.qml +++ /dev/null @@ -1,174 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -import QtQuick 2.0 -import Sailfish.Silica 1.0 -import Sailfish.Accounts 1.0 -import com.jolla.settings.accounts 1.0 -import com.jolla.settings.accounts.mastodon 1.0 -import org.nemomobile.configuration 1.0 - -StandardAccountSettingsDisplay { - id: root - - settingsModified: true - property bool postsServiceEnabled: false - - function refreshDescriptionEditor() { - var description = root.account.configurationValues("")["description"] - var descriptionValue = description ? description.toString().trim() : "" - var credentialsUserName = root.account.defaultCredentialsUserName - ? root.account.defaultCredentialsUserName.toString().trim() - : "" - if (descriptionValue.length > 0 && credentialsUserName !== descriptionValue) { - root.account.setConfigurationValue("", "default_credentials_username", descriptionValue) - } - - // Reuse the standard "Description" field as the account handle editor. - if (descriptionValue.length > 0) { - root.account.displayName = descriptionValue - } else if (credentialsUserName.length > 0) { - root.account.displayName = credentialsUserName - } else { - root.account.displayName = "" - } - } - - function _providerDisplayName() { - var providerDisplayName = root.accountProvider && root.accountProvider.displayName - ? root.accountProvider.displayName.toString().trim() - : "" - //% "Mastodon" - return providerDisplayName.length > 0 ? providerDisplayName : qsTrId("settings-accounts-mastodon-la-provider_name") - } - - onAboutToSaveAccount: { - settingsLoader.updateAllSyncProfiles() - - var storedDescriptionValue = root.account.configurationValues("")["description"] - var storedDescription = storedDescriptionValue ? storedDescriptionValue.toString().trim() : "" - var storedCredentialsUserName = root.account.defaultCredentialsUserName - ? root.account.defaultCredentialsUserName.toString().trim() - : "" - var editedDescription = root.account.displayName - ? root.account.displayName.toString().trim() - : "" - var providerDisplayName = _providerDisplayName() - if (editedDescription === providerDisplayName) { - // Avoid clobbering stored handle if displayName temporarily reverts to provider name. - editedDescription = storedDescription.length > 0 ? storedDescription : storedCredentialsUserName - } - - if (storedDescription !== editedDescription) { - root.account.setConfigurationValue("", "description", editedDescription) - } - - if (storedCredentialsUserName !== editedDescription) { - root.account.setConfigurationValue("", "default_credentials_username", editedDescription) - } - - // Keep account list title fixed to provider name. - root.account.displayName = providerDisplayName - - if (eventsSyncSwitch.checked !== root.account.configurationValues("")["FeedViewAutoSync"]) { - root.account.setConfigurationValue("", "FeedViewAutoSync", eventsSyncSwitch.checked) - } - } - - StandardAccountSettingsLoader { - id: settingsLoader - account: root.account - accountProvider: root.accountProvider - accountManager: root.accountManager - autoEnableServices: root.autoEnableAccount - - onSettingsLoaded: { - syncServicesRepeater.model = syncServices - otherServicesDisplay.serviceModel = otherServices - - refreshDescriptionEditor() - - var autoSync = root.account.configurationValues("")["FeedViewAutoSync"] - var isNewAccount = root.autoEnableAccount - eventsSyncSwitch.checked = (isNewAccount || autoSync === true) - } - } - - Column { - id: syncServicesDisplay - width: parent.width - - SectionHeader { - //: Options for data to be downloaded from a remote server - //% "Download" - text: qsTrId("settings-accounts-la-download_options") - } - - Repeater { - id: syncServicesRepeater - TextSwitch { - checked: model.enabled - text: model.serviceName === "mastodon-microblog" - //% "Posts" - ? qsTrId("settings-accounts-mastodon-la-service_posts") - : (model.serviceName === "mastodon-notifications" - //% "Notifications" - ? qsTrId("settings-accounts-mastodon-la-service_notifications") - : model.displayName) - description: model.serviceName === "mastodon-microblog" - //% "Show Mastodon posts in the Events view." - ? qsTrId("settings-accounts-mastodon-la-service_posts_description") - : (model.serviceName === "mastodon-notifications" - //% "Show Mastodon notifications." - ? qsTrId("settings-accounts-mastodon-la-service_notifications_description") - : "") - visible: text.length > 0 - onCheckedChanged: { - if (model.serviceName === "mastodon-microblog") { - root.postsServiceEnabled = checked - } - if (checked) { - root.account.enableWithService(model.serviceName) - } else { - root.account.disableWithService(model.serviceName) - } - } - } - } - - TextSwitch { - id: eventsSyncSwitch - - //% "Sync Mastodon feed automatically" - text: qsTrId("settings-accounts-mastodon-la-auto_sync_feed") - //% "Fetch new posts periodically when browsing Events Mastodon feed." - description: qsTrId("settings-accounts-mastodon-la-auto_sync_feed_description") - enabled: root.postsServiceEnabled - - onCheckedChanged: { - autoSyncConf.value = checked - } - } - } - - ConfigurationValue { - id: autoSyncConf - key: "/desktop/lipstick-jolla-home/events/auto_sync_feeds/" + root.account.identifier - } - - AccountServiceSettingsDisplay { - id: otherServicesDisplay - enabled: root.accountEnabled - - onUpdateServiceEnabledStatus: { - if (enabled) { - root.account.enableWithService(serviceName) - } else { - root.account.disableWithService(serviceName) - } - } - } -} diff --git a/settings/accounts/ui/fediverse-settings.qml b/settings/accounts/ui/fediverse-settings.qml new file mode 100644 index 0000000..25e0d99 --- /dev/null +++ b/settings/accounts/ui/fediverse-settings.qml @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import Sailfish.Accounts 1.0 +import com.jolla.settings.accounts 1.0 + +AccountSettingsAgent { + id: root + + property string accountSubtitle: { + var instanceDescription = account.configurationValues("")["instance/Description"] + var detail = instanceDescription ? instanceDescription.toString().trim() : "" + if (detail.length > 0) { + return detail + } + + var apiHost = account.configurationValues("")["api/Host"] + var host = apiHost ? apiHost.toString().trim() : "" + host = host.replace(/^https?:\/\//i, "") + var pathSeparator = host.indexOf("/") + if (pathSeparator !== -1) { + host = host.substring(0, pathSeparator) + } + if (host.length > 0) { + return host + } + + var description = account.configurationValues("")["description"] + var handle = description ? description.toString().trim() : "" + if (handle.length > 0) { + return handle + } + + var displayName = account.displayName ? account.displayName.toString().trim() : "" + if (displayName.length > 0) { + return displayName + } + return host + } + + Account { + id: account + identifier: root.accountId + } + + initialPage: Page { + onStatusChanged: { + if (status === PageStatus.Active && !credentialsUpdater.running) { + settingsDisplay.refreshDescriptionEditor() + } + } + + onPageContainerChanged: { + if (pageContainer == null && !credentialsUpdater.running) { + root.delayDeletion = true + settingsDisplay.saveAccount() + } + } + + Component.onDestruction: { + if (status == PageStatus.Active) { + settingsDisplay.saveAccount(true) + } + } + + AccountCredentialsUpdater { + id: credentialsUpdater + } + + SilicaFlickable { + anchors.fill: parent + contentHeight: header.height + settingsDisplay.height + Theme.paddingLarge + + StandardAccountSettingsPullDownMenu { + visible: settingsDisplay.accountValid + allowSync: true + onCredentialsUpdateRequested: { + credentialsUpdater.replaceWithCredentialsUpdatePage(root.accountId) + } + onAccountDeletionRequested: { + root.accountDeletionRequested() + pageStack.pop() + } + onSyncRequested: { + settingsDisplay.saveAccountAndSync() + } + } + + PageHeader { + id: header + title: root.accountsHeaderText + description: root.accountSubtitle + } + + FediverseSettingsDisplay { + id: settingsDisplay + anchors.top: header.bottom + accountManager: root.accountManager + accountProvider: root.accountProvider + accountId: root.accountId + + onAccountSaveCompleted: { + root.delayDeletion = false + } + } + + VerticalScrollDecorator {} + } + } +} diff --git a/settings/accounts/ui/fediverse-update.qml b/settings/accounts/ui/fediverse-update.qml new file mode 100644 index 0000000..fe75089 --- /dev/null +++ b/settings/accounts/ui/fediverse-update.qml @@ -0,0 +1,229 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import Sailfish.Accounts 1.0 +import com.jolla.settings.accounts 1.0 +import com.jolla.settings.accounts.fediverse 1.0 + +AccountCredentialsAgent { + id: root + + property bool _started + + readonly property string callbackUri: "http://ipv4.jolla.com/online/status.html" + readonly property string defaultServerHost: "mastodon.social" + + function normalizeApiHost(rawHost) { + var host = rawHost ? rawHost.trim() : "" + if (host.length === 0) { + host = defaultServerHost + } + + host = host.replace(/^https?:\/\//i, "") + var pathSeparator = host.indexOf("/") + if (pathSeparator !== -1) { + host = host.substring(0, pathSeparator) + } + host = host.replace(/\/+$/, "") + + if (host.length === 0) { + host = defaultServerHost + } + return "https://" + host.toLowerCase() + } + + function _valueFromServiceConfig(config, key) { + return config && config[key] ? config[key].toString() : "" + } + + function _extractAccountName(responseData) { + if (!responseData) { + return "" + } + + var candidates = [ + "AccountUsername", + "UserName", + "user_name", + "acct", + "username", + "preferred_username", + "login", + "ScreenName" + ] + for (var i = 0; i < candidates.length; ++i) { + var value = responseData[candidates[i]] + if (value) { + var userName = value.toString().trim() + if (userName.length > 0) { + return userName + } + } + } + + return "" + } + + function _formatFediverseAccountId(accountName, apiHost) { + var value = accountName ? accountName.toString().trim() : "" + if (value.length === 0) { + return "" + } + + value = value.replace(/^@+/, "") + if (value.indexOf("@") !== -1) { + return "@" + value + } + + var host = apiHost.replace(/^https?:\/\//i, "") + if (host.length === 0) { + return "" + } + + return "@" + value + "@" + host + } + + function _extractAccessToken(responseData) { + if (!responseData) { + return "" + } + + var token = responseData["AccessToken"] + if (!token || token.toString().trim().length === 0) { + token = responseData["access_token"] + } + return token ? token.toString().trim() : "" + } + + function _isFediverseAccountId(value) { + var text = value ? value.toString().trim() : "" + return /^@[^@]+@[^@]+$/.test(text) + } + + function _completeUpdate() { + root.credentialsUpdated(root.accountId) + root.goToEndDestination() + } + + function _saveDescription(description) { + if (description.length > 0) { + account.setConfigurationValue("", "description", description) + if (_isFediverseAccountId(description)) { + account.setConfigurationValue("", "default_credentials_username", description) + } + } + account.sync() + _completeUpdate() + } + + function _updateDescription(responseData) { + var config = account.configurationValues("fediverse-microblog") + var apiHost = normalizeApiHost(_valueFromServiceConfig(config, "api/Host")) + var description = _formatFediverseAccountId(_extractAccountName(responseData), apiHost) + if (description.length > 0) { + _saveDescription(description) + return + } + + var accessToken = _extractAccessToken(responseData) + if (accessToken.length === 0) { + _completeUpdate() + return + } + + var xhr = new XMLHttpRequest() + xhr.onreadystatechange = function() { + if (xhr.readyState !== XMLHttpRequest.DONE) { + return + } + + var fetchedDescription = "" + if (xhr.status >= 200 && xhr.status < 300) { + try { + var response = JSON.parse(xhr.responseText) + fetchedDescription = _formatFediverseAccountId(_extractAccountName(response), apiHost) + } catch (err) { + } + } + + if (fetchedDescription.length > 0) { + _saveDescription(fetchedDescription) + } else { + _completeUpdate() + } + } + + xhr.open("GET", apiHost + "/api/v1/accounts/verify_credentials") + xhr.setRequestHeader("Authorization", "Bearer " + accessToken) + xhr.send() + } + + function _startUpdate() { + if (_started || initialPage.status !== PageStatus.Active || account.status !== Account.Initialized) { + return + } + + var config = account.configurationValues("fediverse-microblog") + var apiHost = normalizeApiHost(_valueFromServiceConfig(config, "api/Host")) + var oauthHost = _valueFromServiceConfig(config, "auth/oauth2/web_server/Host") + if (oauthHost.length === 0) { + oauthHost = apiHost.replace(/^https?:\/\//i, "") + } + + var clientId = _valueFromServiceConfig(config, "auth/oauth2/web_server/ClientId") + var clientSecret = _valueFromServiceConfig(config, "auth/oauth2/web_server/ClientSecret") + if (clientId.length === 0 || clientSecret.length === 0) { + //% "Missing Fediverse OAuth client credentials" + credentialsUpdateError(qsTrId("settings-accounts-fediverse-la-missing_client_credentials")) + return + } + + _started = true + + var sessionData = { + "ClientId": clientId, + "ClientSecret": clientSecret, + "Host": oauthHost, + "AuthPath": "oauth/authorize", + "TokenPath": "oauth/token", + "ResponseType": "code", + "Scope": ["read", "write"], + "RedirectUri": callbackUri + } + initialPage.prepareAccountCredentialsUpdate(account, root.accountProvider, "fediverse-microblog", sessionData) + } + + Account { + id: account + identifier: root.accountId + + onStatusChanged: { + root._startUpdate() + } + } + + initialPage: OAuthAccountSetupPage { + onStatusChanged: { + root._startUpdate() + } + + onAccountCredentialsUpdated: { + root._updateDescription(responseData) + } + + onAccountCredentialsUpdateError: { + root.credentialsUpdateError(errorMessage) + } + + onPageContainerChanged: { + if (pageContainer == null) { + cancelSignIn() + } + } + } +} diff --git a/settings/accounts/ui/fediverse.qml b/settings/accounts/ui/fediverse.qml new file mode 100644 index 0000000..3f5968a --- /dev/null +++ b/settings/accounts/ui/fediverse.qml @@ -0,0 +1,679 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import Sailfish.Accounts 1.0 +import com.jolla.settings.accounts 1.0 +import com.jolla.settings.accounts.fediverse 1.0 + +AccountCreationAgent { + id: root + + property Item _oauthPage + property Item _settingsDialog + property QtObject _accountSetup + + property string _pendingApiHost + property var _pendingInstanceContext: ({}) + property bool _registering + + readonly property string callbackUri: "http://ipv4.jolla.com/online/status.html" + readonly property string defaultServerHost: "mastodon.social" + readonly property string defaultApiHost: normalizeApiHost(defaultServerHost) + readonly property string genericIconPath: "image://theme/icon-l-fediverse" + + function normalizeApiHost(rawHost) { + var host = rawHost ? rawHost.trim() : "" + if (host.length === 0) { + return "" + } + + host = host.replace(/^https?:\/\//i, "") + var pathSeparator = host.indexOf("/") + if (pathSeparator !== -1) { + host = host.substring(0, pathSeparator) + } + host = host.replace(/\/+$/, "") + + if (host.length === 0) { + return "" + } + return "https://" + host.toLowerCase() + } + + function oauthHost(apiHost) { + return apiHost.replace(/^https?:\/\//i, "") + } + + function _trimmedString(value) { + return value ? value.toString().trim() : "" + } + + function _normalizeResourceUrl(rawUrl, apiHost) { + var value = _trimmedString(rawUrl) + if (value.length === 0) { + return "" + } + if (/^https?:\/\//i.test(value)) { + return value + } + if (value.indexOf("//") === 0) { + return "https:" + value + } + if (value.charAt(0) === "/") { + return apiHost + value + } + return apiHost + "/" + value.replace(/^\/+/, "") + } + + function _displayName(apiHost) { + return oauthHost(apiHost) + } + + function _fallbackDisplayName(apiHost) { + var display = _displayName(apiHost) + if (display.length > 0) { + return display + } + return _displayName(defaultApiHost) + } + + function _instanceContext(apiHost) { + var normalizedApiHost = normalizeApiHost(apiHost) + var title = _fallbackDisplayName(normalizedApiHost) + return { + "apiHost": normalizedApiHost, + "oauthHost": oauthHost(normalizedApiHost), + "instanceTitle": title.length > 0 ? title : qsTrId("settings-accounts-fediverse-la-provider_name"), + "instanceDescription": "", + "instanceIconUrl": "", + "instanceIconPath": instanceIconCache.cachedIconPath(normalizedApiHost) + } + } + + function _extractInstanceTitle(responseData, apiHost) { + var candidates = [ + responseData ? responseData.title : "", + responseData ? responseData.name : "", + responseData ? responseData.uri : "" + ] + for (var i = 0; i < candidates.length; ++i) { + var value = _trimmedString(candidates[i]) + if (value.length > 0) { + return value + } + } + return _fallbackDisplayName(apiHost) + } + + function _extractInstanceDescription(responseData) { + var candidates = [ + responseData ? responseData.short_description : "", + responseData ? responseData.description : "", + responseData ? responseData.shortDescription : "" + ] + for (var i = 0; i < candidates.length; ++i) { + var value = _trimmedString(candidates[i]) + if (value.length > 0) { + return value + } + } + return "" + } + + function _extractInstanceIconUrl(responseData, apiHost) { + if (!responseData) { + return "" + } + + if (responseData.thumbnail) { + if (typeof responseData.thumbnail === "string") { + return _normalizeResourceUrl(responseData.thumbnail, apiHost) + } + if (responseData.thumbnail.url) { + return _normalizeResourceUrl(responseData.thumbnail.url, apiHost) + } + if (responseData.thumbnail.static_url) { + return _normalizeResourceUrl(responseData.thumbnail.static_url, apiHost) + } + } + + if (responseData.icon) { + if (typeof responseData.icon === "string") { + return _normalizeResourceUrl(responseData.icon, apiHost) + } + if (responseData.icon.url) { + return _normalizeResourceUrl(responseData.icon.url, apiHost) + } + } + + return "" + } + + function _showRegistrationError(message, busyPage) { + _registering = false + accountCreationError(message) + if (busyPage) { + busyPage.state = "info" + busyPage.infoDescription = message + busyPage.infoExtraDescription = "" + busyPage.infoButtonText = "" + } + } + + function _showOAuthPage(context) { + _registering = false + if (_oauthPage != null) { + _oauthPage.cancelSignIn() + _oauthPage.destroy() + } + _oauthPage = oAuthComponent.createObject(root, { "context": context }) + pageStack.replace(_oauthPage) + } + + function _registerClientApplication(context, busyPage) { + if (_registering) { + return + } + _registering = true + + if (context.instanceIconUrl.length > 0 && context.instanceIconPath.length === 0) { + instanceIconCache.cacheIcon(context.apiHost, context.instanceIconUrl) + } + + var xhr = new XMLHttpRequest() + xhr.onreadystatechange = function() { + if (xhr.readyState !== XMLHttpRequest.DONE) { + return + } + + if (xhr.status < 200 || xhr.status >= 300) { + _showRegistrationError(qsTrId("settings-accounts-fediverse-la-register_app_failed").arg(context.apiHost), busyPage) + return + } + + var response + try { + response = JSON.parse(xhr.responseText) + } catch (err) { + _showRegistrationError(qsTrId("settings-accounts-fediverse-la-invalid_app_registration_response"), busyPage) + return + } + + if (!response.client_id || !response.client_secret) { + _showRegistrationError(qsTrId("settings-accounts-fediverse-la-app_registration_missing_credentials"), busyPage) + return + } + + context.clientId = response.client_id + context.clientSecret = response.client_secret + _showOAuthPage(context) + } + + var postData = [] + postData.push("client_name=" + encodeURIComponent(qsTrId("settings-accounts-fediverse-la-client_name"))) + postData.push("redirect_uris=" + encodeURIComponent(callbackUri)) + postData.push("scopes=" + encodeURIComponent("read write")) + postData.push("website=" + encodeURIComponent("https://sailfishos.org")) + + xhr.open("POST", context.apiHost + "/api/v1/apps") + xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded") + xhr.send(postData.join("&")) + } + + function _discoverInstanceContext(apiHost, busyPage) { + var context = _instanceContext(apiHost) + _pendingInstanceContext = context + + function continueWithContext() { + _pendingInstanceContext = context + _registerClientApplication(context, busyPage) + } + + function loadEndpoint(paths, index) { + if (index >= paths.length) { + continueWithContext() + return + } + + var xhr = new XMLHttpRequest() + xhr.onreadystatechange = function() { + if (xhr.readyState !== XMLHttpRequest.DONE) { + return + } + + if (xhr.status >= 200 && xhr.status < 300) { + try { + var response = JSON.parse(xhr.responseText) + context.instanceTitle = _extractInstanceTitle(response, context.apiHost) + context.instanceDescription = _extractInstanceDescription(response) + context.instanceIconUrl = _extractInstanceIconUrl(response, context.apiHost) + if (context.instanceIconUrl.length > 0) { + var cachedPath = instanceIconCache.cachedIconPath(context.apiHost) + if (cachedPath.length > 0) { + context.instanceIconPath = cachedPath + } + } + } catch (err) { + } + continueWithContext() + return + } + + loadEndpoint(paths, index + 1) + } + + xhr.open("GET", context.apiHost + paths[index]) + xhr.send() + } + + loadEndpoint(["/api/v2/instance", "/api/v1/instance"], 0) + } + + function _extractAccountName(responseData) { + if (!responseData) { + return "" + } + + var candidates = [ + "AccountUsername", + "UserName", + "user_name", + "acct", + "username", + "preferred_username", + "login", + "ScreenName" + ] + for (var i = 0; i < candidates.length; ++i) { + var value = responseData[candidates[i]] + if (value) { + var userName = value.toString().trim() + if (userName.length > 0) { + return userName + } + } + } + + return "" + } + + function _formatFediverseAccountId(accountName, apiHost) { + var value = accountName ? accountName.toString().trim() : "" + if (value.length === 0) { + return "" + } + + value = value.replace(/^@+/, "") + if (value.indexOf("@") !== -1) { + return "@" + value + } + + var host = oauthHost(apiHost) + if (host.length === 0) { + return "" + } + + return "@" + value + "@" + host + } + + function _isFediverseAccountId(value) { + var text = value ? value.toString().trim() : "" + return /^@[^@]+@[^@]+$/.test(text) + } + + function _extractAccessToken(responseData) { + if (!responseData) { + return "" + } + + var token = responseData["AccessToken"] + if (!token || token.toString().trim().length === 0) { + token = responseData["access_token"] + } + return token ? token.toString().trim() : "" + } + + function _handleAccountCreated(accountId, context, responseData) { + var props = { + "accountId": accountId, + "apiHost": context.apiHost, + "oauthHost": context.oauthHost, + "clientId": context.clientId, + "clientSecret": context.clientSecret, + "accessToken": _extractAccessToken(responseData), + "accountDescription": _formatFediverseAccountId(_extractAccountName(responseData), context.apiHost), + "instanceTitle": context.instanceTitle, + "instanceDescription": context.instanceDescription, + "instanceIconUrl": context.instanceIconUrl, + "instanceIconPath": context.instanceIconPath + } + _accountSetup = accountSetupComponent.createObject(root, props) + _accountSetup.done.connect(function() { + accountCreated(accountId) + _goToSettings(accountId) + }) + _accountSetup.error.connect(function() { + accountCreationError(qsTrId("settings-accounts-fediverse-la-account_setup_failed")) + }) + } + + function _goToSettings(accountId) { + if (_settingsDialog != null) { + _settingsDialog.destroy() + } + _settingsDialog = settingsComponent.createObject(root, {"accountId": accountId}) + pageStack.replace(_settingsDialog) + } + + FediverseInstanceIconCache { + id: instanceIconCache + + onIconReady: { + var normalizedHost = root.normalizeApiHost(apiHost) + if (root._pendingInstanceContext.apiHost === normalizedHost) { + root._pendingInstanceContext.instanceIconPath = iconPath + } + if (root._accountSetup && root._accountSetup.apiHost === normalizedHost) { + root._accountSetup.updateInstanceIcon(iconPath) + } + } + } + + initialPage: Dialog { + id: setupDialog + + property string normalizedHost: root.normalizeApiHost(instanceField.text) + + canAccept: !root._registering + acceptDestinationAction: PageStackAction.Push + acceptDestination: busyComponent + + onAccepted: { + root._pendingApiHost = normalizedHost.length > 0 ? normalizedHost : root.defaultApiHost + } + + DialogHeader { + id: header + acceptText: qsTrId("settings-accounts-common-bt-sign_in") + } + + Column { + anchors.top: header.bottom + anchors.topMargin: Theme.paddingLarge + spacing: Theme.paddingLarge + width: parent.width + + Row { + x: Theme.horizontalPageMargin + width: parent.width - x * 2 + spacing: Theme.paddingMedium + + Image { + id: promptIcon + width: Theme.iconSizeMedium + height: Theme.iconSizeMedium + source: root.genericIconPath + fillMode: Image.PreserveAspectFit + sourceSize.width: width + sourceSize.height: height + } + + Label { + width: parent.width - promptIcon.width - parent.spacing + wrapMode: Text.Wrap + color: Theme.highlightColor + text: qsTrId("settings-accounts-fediverse-la-enter_server_then_sign_in") + } + } + + TextField { + id: instanceField + x: Theme.horizontalPageMargin + width: parent.width - x * 2 + label: qsTrId("settings-accounts-fediverse-la-server") + placeholderText: root.defaultServerHost + inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhUrlCharactersOnly + EnterKey.iconSource: "image://theme/icon-m-enter-next" + EnterKey.onClicked: { + if (setupDialog.canAccept) { + setupDialog.accept() + } + } + } + } + } + + Component { + id: busyComponent + AccountBusyPage { + busyDescription: qsTrId("settings-accounts-fediverse-la-preparing_sign_in") + onStatusChanged: { + if (status === PageStatus.Active && root._pendingApiHost.length > 0) { + root._discoverInstanceContext(root._pendingApiHost, this) + } + } + } + } + + Component { + id: oAuthComponent + OAuthAccountSetupPage { + property var context + + Component.onCompleted: { + var sessionData = { + "ClientId": context.clientId, + "ClientSecret": context.clientSecret, + "Host": context.oauthHost, + "AuthPath": "oauth/authorize", + "TokenPath": "oauth/token", + "ResponseType": "code", + "Scope": ["read", "write"], + "RedirectUri": root.callbackUri + } + prepareAccountCreation(root.accountProvider, "fediverse-microblog", sessionData) + } + + onAccountCreated: { + root._handleAccountCreated(accountId, context, responseData) + } + + onAccountCreationError: { + root.accountCreationError(errorMessage) + } + } + } + + Component { + id: accountSetupComponent + QtObject { + id: accountSetup + + property int accountId + property string apiHost + property string oauthHost + property string clientId + property string clientSecret + property string accessToken + property string accountDescription + property string instanceTitle + property string instanceDescription + property string instanceIconUrl + property string instanceIconPath + property bool hasConfigured + + signal done() + signal error() + + property Account newAccount: Account { + identifier: accountSetup.accountId + + onStatusChanged: { + if (status === Account.Initialized || status === Account.Synced) { + if (!accountSetup.hasConfigured) { + accountSetup.configure() + } else { + accountSetup.done() + } + } else if (status === Account.Invalid && accountSetup.hasConfigured) { + accountSetup.error() + } + } + } + + function _serviceNames() { + return ["fediverse-microblog", "fediverse-notifications", "fediverse-sharing"] + } + + function _effectiveInstanceTitle() { + var title = root._trimmedString(instanceTitle) + return title.length > 0 ? title : qsTrId("settings-accounts-fediverse-la-provider_name") + } + + function _effectiveIconPath() { + return root._trimmedString(instanceIconPath).length > 0 ? instanceIconPath : root.genericIconPath + } + + function _applyIconPath(iconPath) { + instanceIconPath = iconPath + newAccount.setConfigurationValue("", "iconPath", _effectiveIconPath()) + var services = _serviceNames() + for (var i = 0; i < services.length; ++i) { + newAccount.setConfigurationValue(services[i], "iconPath", _effectiveIconPath()) + } + if (hasConfigured) { + newAccount.sync() + } + } + + function updateInstanceIcon(iconPath) { + if (root._trimmedString(iconPath).length === 0) { + return + } + _applyIconPath(iconPath) + } + + function configure() { + hasConfigured = true + + var services = _serviceNames() + var providerDisplayName = _effectiveInstanceTitle() + newAccount.displayName = providerDisplayName + + newAccount.setConfigurationValue("", "api/Host", apiHost) + newAccount.setConfigurationValue("", "FeedViewAutoSync", true) + newAccount.setConfigurationValue("", "instance/Title", providerDisplayName) + newAccount.setConfigurationValue("", "instance/Description", root._trimmedString(instanceDescription)) + newAccount.setConfigurationValue("", "instance/IconUrl", root._trimmedString(instanceIconUrl)) + _applyIconPath(_effectiveIconPath()) + + if (accountDescription.length > 0) { + newAccount.setConfigurationValue("", "description", accountDescription) + if (root._isFediverseAccountId(accountDescription)) { + newAccount.setConfigurationValue("", "default_credentials_username", accountDescription) + } + } + + for (var i = 0; i < services.length; ++i) { + var service = services[i] + newAccount.setConfigurationValue(service, "api/Host", apiHost) + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/Host", oauthHost) + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/AuthPath", "oauth/authorize") + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/TokenPath", "oauth/token") + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/ResponseType", "code") + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/RedirectUri", root.callbackUri) + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/Scope", ["read", "write"]) + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/ClientId", clientId) + newAccount.setConfigurationValue(service, "auth/oauth2/web_server/ClientSecret", clientSecret) + } + + for (var j = 0; j < services.length; ++j) { + newAccount.enableWithService(services[j]) + } + + if (instanceIconUrl.length > 0 && instanceIconPath.length === 0) { + instanceIconCache.cacheIcon(apiHost, instanceIconUrl) + } + + if (accountDescription.length > 0 || accessToken.length === 0) { + newAccount.sync() + return + } + + var xhr = new XMLHttpRequest() + xhr.onreadystatechange = function() { + if (xhr.readyState !== XMLHttpRequest.DONE) { + return + } + + if (xhr.status >= 200 && xhr.status < 300) { + try { + var response = JSON.parse(xhr.responseText) + var fetchedDescription = root._formatFediverseAccountId(root._extractAccountName(response), apiHost) + if (fetchedDescription.length > 0) { + accountDescription = fetchedDescription + newAccount.setConfigurationValue("", "description", fetchedDescription) + if (root._isFediverseAccountId(fetchedDescription)) { + newAccount.setConfigurationValue("", "default_credentials_username", fetchedDescription) + } + } + } catch (err) { + } + } + + newAccount.sync() + } + + xhr.open("GET", apiHost + "/api/v1/accounts/verify_credentials") + xhr.setRequestHeader("Authorization", "Bearer " + accessToken) + xhr.send() + } + } + } + + Component { + id: settingsComponent + Dialog { + property alias accountId: settingsDisplay.accountId + + acceptDestination: root.endDestination + acceptDestinationAction: root.endDestinationAction + acceptDestinationProperties: root.endDestinationProperties + acceptDestinationReplaceTarget: root.endDestinationReplaceTarget + backNavigation: false + + onAccepted: { + root.delayDeletion = true + settingsDisplay.saveAccountAndSync() + } + + SilicaFlickable { + anchors.fill: parent + contentHeight: header.height + settingsDisplay.height + + DialogHeader { + id: header + } + + FediverseSettingsDisplay { + id: settingsDisplay + anchors.top: header.bottom + accountManager: root.accountManager + accountProvider: root.accountProvider + autoEnableAccount: true + + onAccountSaveCompleted: { + root.delayDeletion = false + } + } + + VerticalScrollDecorator {} + } + } + } +} diff --git a/settings/accounts/ui/mastodon-settings.qml b/settings/accounts/ui/mastodon-settings.qml deleted file mode 100644 index 0538e44..0000000 --- a/settings/accounts/ui/mastodon-settings.qml +++ /dev/null @@ -1,107 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -import QtQuick 2.0 -import Sailfish.Silica 1.0 -import Sailfish.Accounts 1.0 -import com.jolla.settings.accounts 1.0 - -AccountSettingsAgent { - id: root - - property string accountSubtitle: { - var description = account.configurationValues("")["description"] - var detail = description ? description.toString().trim() : "" - if (detail.length > 0) { - return detail - } - var apiHost = account.configurationValues("")["api/Host"] - var host = apiHost ? apiHost.toString().trim() : "" - host = host.replace(/^https?:\/\//i, "") - var pathSeparator = host.indexOf("/") - if (pathSeparator !== -1) { - host = host.substring(0, pathSeparator) - } - if (host.length > 0) { - return host - } - var displayName = account.displayName ? account.displayName.toString().trim() : "" - if (displayName.length > 0) { - return displayName - } - return host - } - - Account { - id: account - identifier: root.accountId - } - - initialPage: Page { - onStatusChanged: { - if (status === PageStatus.Active && !credentialsUpdater.running) { - settingsDisplay.refreshDescriptionEditor() - } - } - - onPageContainerChanged: { - if (pageContainer == null && !credentialsUpdater.running) { - root.delayDeletion = true - settingsDisplay.saveAccount() - } - } - - Component.onDestruction: { - if (status == PageStatus.Active) { - settingsDisplay.saveAccount(true) - } - } - - AccountCredentialsUpdater { - id: credentialsUpdater - } - - SilicaFlickable { - anchors.fill: parent - contentHeight: header.height + settingsDisplay.height + Theme.paddingLarge - - StandardAccountSettingsPullDownMenu { - visible: settingsDisplay.accountValid - allowSync: true - onCredentialsUpdateRequested: { - credentialsUpdater.replaceWithCredentialsUpdatePage(root.accountId) - } - onAccountDeletionRequested: { - root.accountDeletionRequested() - pageStack.pop() - } - onSyncRequested: { - settingsDisplay.saveAccountAndSync() - } - } - - PageHeader { - id: header - title: root.accountsHeaderText - description: root.accountSubtitle - } - - MastodonSettingsDisplay { - id: settingsDisplay - anchors.top: header.bottom - accountManager: root.accountManager - accountProvider: root.accountProvider - accountId: root.accountId - - onAccountSaveCompleted: { - root.delayDeletion = false - } - } - - VerticalScrollDecorator {} - } - } -} diff --git a/settings/accounts/ui/mastodon-update.qml b/settings/accounts/ui/mastodon-update.qml deleted file mode 100644 index 2485a83..0000000 --- a/settings/accounts/ui/mastodon-update.qml +++ /dev/null @@ -1,228 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -import QtQuick 2.0 -import Sailfish.Silica 1.0 -import Sailfish.Accounts 1.0 -import com.jolla.settings.accounts 1.0 -import com.jolla.settings.accounts.mastodon 1.0 - -AccountCredentialsAgent { - id: root - - property bool _started - - readonly property string callbackUri: "http://ipv4.jolla.com/online/status.html" - - function normalizeApiHost(rawHost) { - var host = rawHost ? rawHost.trim() : "" - if (host.length === 0) { - host = "https://mastodon.social" - } - - host = host.replace(/^https?:\/\//i, "") - var pathSeparator = host.indexOf("/") - if (pathSeparator !== -1) { - host = host.substring(0, pathSeparator) - } - host = host.replace(/\/+$/, "") - - if (host.length === 0) { - host = "mastodon.social" - } - return "https://" + host.toLowerCase() - } - - function _valueFromServiceConfig(config, key) { - return config && config[key] ? config[key].toString() : "" - } - - function _extractAccountName(responseData) { - if (!responseData) { - return "" - } - - var candidates = [ - "AccountUsername", - "UserName", - "user_name", - "acct", - "username", - "preferred_username", - "login", - "ScreenName" - ] - for (var i = 0; i < candidates.length; ++i) { - var value = responseData[candidates[i]] - if (value) { - var userName = value.toString().trim() - if (userName.length > 0) { - return userName - } - } - } - - return "" - } - - function _formatMastodonAccountId(accountName, apiHost) { - var value = accountName ? accountName.toString().trim() : "" - if (value.length === 0) { - return "" - } - - value = value.replace(/^@+/, "") - if (value.indexOf("@") !== -1) { - return "@" + value - } - - var host = apiHost.replace(/^https?:\/\//i, "") - if (host.length === 0) { - return "" - } - - return "@" + value + "@" + host - } - - function _extractAccessToken(responseData) { - if (!responseData) { - return "" - } - - var token = responseData["AccessToken"] - if (!token || token.toString().trim().length === 0) { - token = responseData["access_token"] - } - return token ? token.toString().trim() : "" - } - - function _isMastodonAccountId(value) { - var text = value ? value.toString().trim() : "" - return /^@[^@]+@[^@]+$/.test(text) - } - - function _completeUpdate() { - root.credentialsUpdated(root.accountId) - root.goToEndDestination() - } - - function _saveDescription(description) { - if (description.length > 0) { - account.setConfigurationValue("", "description", description) - if (_isMastodonAccountId(description)) { - account.setConfigurationValue("", "default_credentials_username", description) - } - } - account.sync() - _completeUpdate() - } - - function _updateDescription(responseData) { - var config = account.configurationValues("mastodon-microblog") - var apiHost = normalizeApiHost(_valueFromServiceConfig(config, "api/Host")) - var description = _formatMastodonAccountId(_extractAccountName(responseData), apiHost) - if (description.length > 0) { - _saveDescription(description) - return - } - - var accessToken = _extractAccessToken(responseData) - if (accessToken.length === 0) { - _completeUpdate() - return - } - - var xhr = new XMLHttpRequest() - xhr.onreadystatechange = function() { - if (xhr.readyState !== XMLHttpRequest.DONE) { - return - } - - var fetchedDescription = "" - if (xhr.status >= 200 && xhr.status < 300) { - try { - var response = JSON.parse(xhr.responseText) - fetchedDescription = _formatMastodonAccountId(_extractAccountName(response), apiHost) - } catch (err) { - } - } - - if (fetchedDescription.length > 0) { - _saveDescription(fetchedDescription) - } else { - _completeUpdate() - } - } - - xhr.open("GET", apiHost + "/api/v1/accounts/verify_credentials") - xhr.setRequestHeader("Authorization", "Bearer " + accessToken) - xhr.send() - } - - function _startUpdate() { - if (_started || initialPage.status !== PageStatus.Active || account.status !== Account.Initialized) { - return - } - - var config = account.configurationValues("mastodon-microblog") - var apiHost = normalizeApiHost(_valueFromServiceConfig(config, "api/Host")) - var oauthHost = _valueFromServiceConfig(config, "auth/oauth2/web_server/Host") - if (oauthHost.length === 0) { - oauthHost = apiHost.replace(/^https?:\/\//i, "") - } - - var clientId = _valueFromServiceConfig(config, "auth/oauth2/web_server/ClientId") - var clientSecret = _valueFromServiceConfig(config, "auth/oauth2/web_server/ClientSecret") - if (clientId.length === 0 || clientSecret.length === 0) { - //% "Missing Mastodon OAuth client credentials" - credentialsUpdateError(qsTrId("settings-accounts-mastodon-la-missing_client_credentials")) - return - } - - _started = true - - var sessionData = { - "ClientId": clientId, - "ClientSecret": clientSecret, - "Host": oauthHost, - "AuthPath": "oauth/authorize", - "TokenPath": "oauth/token", - "ResponseType": "code", - "Scope": ["read", "write"], - "RedirectUri": callbackUri - } - initialPage.prepareAccountCredentialsUpdate(account, root.accountProvider, "mastodon-microblog", sessionData) - } - - Account { - id: account - identifier: root.accountId - - onStatusChanged: { - root._startUpdate() - } - } - - initialPage: OAuthAccountSetupPage { - onStatusChanged: { - root._startUpdate() - } - - onAccountCredentialsUpdated: { - root._updateDescription(responseData) - } - - onAccountCredentialsUpdateError: { - root.credentialsUpdateError(errorMessage) - } - - onPageContainerChanged: { - if (pageContainer == null) { - cancelSignIn() - } - } - } -} diff --git a/settings/accounts/ui/mastodon.qml b/settings/accounts/ui/mastodon.qml deleted file mode 100644 index 129fda5..0000000 --- a/settings/accounts/ui/mastodon.qml +++ /dev/null @@ -1,492 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -import QtQuick 2.0 -import Sailfish.Silica 1.0 -import Sailfish.Accounts 1.0 -import com.jolla.settings.accounts 1.0 -import com.jolla.settings.accounts.mastodon 1.0 - -AccountCreationAgent { - id: root - - property Item _oauthPage - property Item _settingsDialog - property QtObject _accountSetup - - property string _pendingApiHost - property bool _registering - - readonly property string callbackUri: "http://ipv4.jolla.com/online/status.html" - readonly property string defaultApiHost: "https://mastodon.social" - - function normalizeApiHost(rawHost) { - var host = rawHost ? rawHost.trim() : "" - if (host.length === 0) { - return "" - } - - host = host.replace(/^https?:\/\//i, "") - var pathSeparator = host.indexOf("/") - if (pathSeparator !== -1) { - host = host.substring(0, pathSeparator) - } - host = host.replace(/\/+$/, "") - - if (host.length === 0) { - return "" - } - return "https://" + host.toLowerCase() - } - - function oauthHost(apiHost) { - return apiHost.replace(/^https?:\/\//i, "") - } - - function _displayName(apiHost) { - return oauthHost(apiHost) - } - - function _fallbackDisplayName(apiHost) { - var display = _displayName(apiHost) - if (display.length > 0) { - return display - } - return _displayName(defaultApiHost) - } - - function _showRegistrationError(message, busyPage) { - _registering = false - accountCreationError(message) - if (busyPage) { - busyPage.state = "info" - busyPage.infoDescription = message - busyPage.infoExtraDescription = "" - busyPage.infoButtonText = "" - } - } - - function _showOAuthPage(context) { - _registering = false - if (_oauthPage != null) { - _oauthPage.cancelSignIn() - _oauthPage.destroy() - } - _oauthPage = oAuthComponent.createObject(root, { "context": context }) - pageStack.replace(_oauthPage) - } - - function _registerClientApplication(apiHost, busyPage) { - if (_registering) { - return - } - _registering = true - - var xhr = new XMLHttpRequest() - xhr.onreadystatechange = function() { - if (xhr.readyState !== XMLHttpRequest.DONE) { - return - } - - if (xhr.status < 200 || xhr.status >= 300) { - //% "Failed to register Mastodon app for %1" - _showRegistrationError(qsTrId("settings-accounts-mastodon-la-register_app_failed").arg(apiHost), busyPage) - return - } - - var response - try { - response = JSON.parse(xhr.responseText) - } catch (err) { - //% "Invalid Mastodon app registration response" - _showRegistrationError(qsTrId("settings-accounts-mastodon-la-invalid_app_registration_response"), busyPage) - return - } - - if (!response.client_id || !response.client_secret) { - //% "Mastodon app registration did not return credentials" - _showRegistrationError(qsTrId("settings-accounts-mastodon-la-app_registration_missing_credentials"), busyPage) - return - } - - _showOAuthPage({ - "apiHost": apiHost, - "oauthHost": oauthHost(apiHost), - "clientId": response.client_id, - "clientSecret": response.client_secret - }) - } - - var postData = [] - //% "Mastodon in SailfishOS" - postData.push("client_name=" + encodeURIComponent(qsTrId("settings-accounts-mastodon-la-client_name"))) - postData.push("redirect_uris=" + encodeURIComponent(callbackUri)) - postData.push("scopes=" + encodeURIComponent("read write")) - postData.push("website=" + encodeURIComponent("https://sailfishos.org")) - - xhr.open("POST", apiHost + "/api/v1/apps") - xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded") - xhr.send(postData.join("&")) - } - - function _extractAccountName(responseData) { - if (!responseData) { - return "" - } - - var candidates = [ - "AccountUsername", - "UserName", - "user_name", - "acct", - "username", - "preferred_username", - "login", - "ScreenName" - ] - for (var i = 0; i < candidates.length; ++i) { - var value = responseData[candidates[i]] - if (value) { - var userName = value.toString().trim() - if (userName.length > 0) { - return userName - } - } - } - - return "" - } - - function _formatMastodonAccountId(accountName, apiHost) { - var value = accountName ? accountName.toString().trim() : "" - if (value.length === 0) { - return "" - } - - value = value.replace(/^@+/, "") - if (value.indexOf("@") !== -1) { - return "@" + value - } - - var host = oauthHost(apiHost) - if (host.length === 0) { - return "" - } - - return "@" + value + "@" + host - } - - function _isMastodonAccountId(value) { - var text = value ? value.toString().trim() : "" - return /^@[^@]+@[^@]+$/.test(text) - } - - function _extractAccessToken(responseData) { - if (!responseData) { - return "" - } - - var token = responseData["AccessToken"] - if (!token || token.toString().trim().length === 0) { - token = responseData["access_token"] - } - return token ? token.toString().trim() : "" - } - - function _handleAccountCreated(accountId, context, responseData) { - var props = { - "accountId": accountId, - "apiHost": context.apiHost, - "oauthHost": context.oauthHost, - "clientId": context.clientId, - "clientSecret": context.clientSecret, - "accessToken": _extractAccessToken(responseData), - "accountDescription": _formatMastodonAccountId(_extractAccountName(responseData), context.apiHost) - } - _accountSetup = accountSetupComponent.createObject(root, props) - _accountSetup.done.connect(function() { - accountCreated(accountId) - _goToSettings(accountId) - }) - _accountSetup.error.connect(function() { - //% "Failed to finish Mastodon account setup" - accountCreationError(qsTrId("settings-accounts-mastodon-la-account_setup_failed")) - }) - } - - function _goToSettings(accountId) { - if (_settingsDialog != null) { - _settingsDialog.destroy() - } - _settingsDialog = settingsComponent.createObject(root, {"accountId": accountId}) - pageStack.replace(_settingsDialog) - } - - initialPage: Dialog { - id: setupDialog - - property string normalizedHost: root.normalizeApiHost(instanceField.text) - - canAccept: !root._registering - acceptDestinationAction: PageStackAction.Push - acceptDestination: busyComponent - - onAccepted: { - root._pendingApiHost = normalizedHost.length > 0 ? normalizedHost : root.defaultApiHost - } - - DialogHeader { - id: header - //% "Sign in" - acceptText: qsTrId("settings-accounts-common-bt-sign_in") - } - - Column { - anchors.top: header.bottom - anchors.topMargin: Theme.paddingLarge - spacing: Theme.paddingLarge - width: parent.width - - Row { - x: Theme.horizontalPageMargin - width: parent.width - x * 2 - spacing: Theme.paddingMedium - - Image { - id: promptIcon - width: Theme.iconSizeMedium - height: Theme.iconSizeMedium - source: "image://theme/icon-l-mastodon" - fillMode: Image.PreserveAspectFit - sourceSize.width: width - sourceSize.height: height - } - - //: Prompt shown in account setup before OAuth sign-in. - Label { - width: parent.width - promptIcon.width - parent.spacing - wrapMode: Text.Wrap - color: Theme.highlightColor - //% "Enter your Mastodon server, then sign in." - text: qsTrId("settings-accounts-mastodon-la-enter_server_then_sign_in") - } - } - - TextField { - id: instanceField - x: Theme.horizontalPageMargin - width: parent.width - x * 2 - //% "Server" - label: qsTrId("settings-accounts-mastodon-la-server") - placeholderText: "mastodon.social" - inputMethodHints: Qt.ImhNoAutoUppercase | Qt.ImhUrlCharactersOnly - EnterKey.iconSource: "image://theme/icon-m-enter-next" - EnterKey.onClicked: { - if (setupDialog.canAccept) { - setupDialog.accept() - } - } - } - } - } - - Component { - id: busyComponent - AccountBusyPage { - //% "Preparing Mastodon sign-in..." - busyDescription: qsTrId("settings-accounts-mastodon-la-preparing_sign_in") - onStatusChanged: { - if (status === PageStatus.Active && root._pendingApiHost.length > 0) { - root._registerClientApplication(root._pendingApiHost, this) - } - } - } - } - - Component { - id: oAuthComponent - OAuthAccountSetupPage { - property var context - - Component.onCompleted: { - var sessionData = { - "ClientId": context.clientId, - "ClientSecret": context.clientSecret, - "Host": context.oauthHost, - "AuthPath": "oauth/authorize", - "TokenPath": "oauth/token", - "ResponseType": "code", - "Scope": ["read", "write"], - "RedirectUri": root.callbackUri - } - prepareAccountCreation(root.accountProvider, "mastodon-microblog", sessionData) - } - - onAccountCreated: { - root._handleAccountCreated(accountId, context, responseData) - } - - onAccountCreationError: { - root.accountCreationError(errorMessage) - } - } - } - - Component { - id: accountSetupComponent - QtObject { - id: accountSetup - - property int accountId - property string apiHost - property string oauthHost - property string clientId - property string clientSecret - property string accessToken - property string accountDescription - property bool hasConfigured - - signal done() - signal error() - - property Account newAccount: Account { - identifier: accountSetup.accountId - - onStatusChanged: { - if (status === Account.Initialized || status === Account.Synced) { - if (!accountSetup.hasConfigured) { - accountSetup.configure() - } else { - accountSetup.done() - } - } else if (status === Account.Invalid && accountSetup.hasConfigured) { - accountSetup.error() - } - } - } - - function configure() { - hasConfigured = true - - var services = ["mastodon-microblog", "mastodon-notifications", "mastodon-sharing"] - var providerDisplayName = root.accountProvider && root.accountProvider.displayName - ? root.accountProvider.displayName.toString().trim() - : "" - if (providerDisplayName.length === 0) { - //% "Mastodon" - providerDisplayName = qsTrId("settings-accounts-mastodon-la-provider_name") - } - newAccount.displayName = providerDisplayName - - newAccount.setConfigurationValue("", "api/Host", apiHost) - newAccount.setConfigurationValue("", "FeedViewAutoSync", true) - if (accountDescription.length > 0) { - newAccount.setConfigurationValue("", "description", accountDescription) - if (root._isMastodonAccountId(accountDescription)) { - newAccount.setConfigurationValue("", "default_credentials_username", accountDescription) - } - } else { - var hostDisplayName = root._fallbackDisplayName(apiHost) - if (hostDisplayName.length > 0) { - newAccount.setConfigurationValue("", "description", hostDisplayName) - } - } - - for (var i = 0; i < services.length; ++i) { - var service = services[i] - newAccount.setConfigurationValue(service, "api/Host", apiHost) - newAccount.setConfigurationValue(service, "auth/oauth2/web_server/Host", oauthHost) - newAccount.setConfigurationValue(service, "auth/oauth2/web_server/AuthPath", "oauth/authorize") - newAccount.setConfigurationValue(service, "auth/oauth2/web_server/TokenPath", "oauth/token") - newAccount.setConfigurationValue(service, "auth/oauth2/web_server/ResponseType", "code") - newAccount.setConfigurationValue(service, "auth/oauth2/web_server/RedirectUri", root.callbackUri) - newAccount.setConfigurationValue(service, "auth/oauth2/web_server/Scope", ["read", "write"]) - newAccount.setConfigurationValue(service, "auth/oauth2/web_server/ClientId", clientId) - newAccount.setConfigurationValue(service, "auth/oauth2/web_server/ClientSecret", clientSecret) - } - - for (var j = 0; j < services.length; ++j) { - newAccount.enableWithService(services[j]) - } - - if (accountDescription.length > 0 || accessToken.length === 0) { - newAccount.sync() - return - } - - var xhr = new XMLHttpRequest() - xhr.onreadystatechange = function() { - if (xhr.readyState !== XMLHttpRequest.DONE) { - return - } - - if (xhr.status >= 200 && xhr.status < 300) { - try { - var response = JSON.parse(xhr.responseText) - var fetchedDescription = root._formatMastodonAccountId(root._extractAccountName(response), apiHost) - if (fetchedDescription.length > 0) { - accountDescription = fetchedDescription - newAccount.setConfigurationValue("", "description", fetchedDescription) - if (root._isMastodonAccountId(fetchedDescription)) { - newAccount.setConfigurationValue("", "default_credentials_username", fetchedDescription) - } - } - } catch (err) { - } - } - - newAccount.sync() - } - - xhr.open("GET", apiHost + "/api/v1/accounts/verify_credentials") - xhr.setRequestHeader("Authorization", "Bearer " + accessToken) - xhr.send() - } - } - } - - Component { - id: settingsComponent - Dialog { - property alias accountId: settingsDisplay.accountId - - acceptDestination: root.endDestination - acceptDestinationAction: root.endDestinationAction - acceptDestinationProperties: root.endDestinationProperties - acceptDestinationReplaceTarget: root.endDestinationReplaceTarget - backNavigation: false - - onAccepted: { - root.delayDeletion = true - settingsDisplay.saveAccountAndSync() - } - - SilicaFlickable { - anchors.fill: parent - contentHeight: header.height + settingsDisplay.height - - DialogHeader { - id: header - } - - MastodonSettingsDisplay { - id: settingsDisplay - anchors.top: header.bottom - accountManager: root.accountManager - accountProvider: root.accountProvider - autoEnableAccount: true - - onAccountSaveCompleted: { - root.delayDeletion = false - } - } - - VerticalScrollDecorator {} - } - } - } - -} diff --git a/transferengine-plugins/fediverseshareplugin/FediverseSharePost.qml b/transferengine-plugins/fediverseshareplugin/FediverseSharePost.qml new file mode 100644 index 0000000..d859d96 --- /dev/null +++ b/transferengine-plugins/fediverseshareplugin/FediverseSharePost.qml @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +import QtQuick 2.6 +import Sailfish.Silica 1.0 +import Sailfish.Lipstick 1.0 +import Sailfish.TransferEngine 1.0 + +Item { + id: root + + property var shareAction + property string mimeType: { + if (shareAction && shareAction.mimeType) { + return shareAction.mimeType + } + if (shareAction && shareAction.resources + && shareAction.resources.length > 0 + && shareAction.resources[0] + && shareAction.resources[0].type) { + return shareAction.resources[0].type + } + return "" + } + property bool textShare: mimeType === "text/x-url" || mimeType === "text/plain" + + width: parent ? parent.width : 0 + height: previewLoader.item ? previewLoader.item.height : 0 + + Loader { + id: previewLoader + + anchors.fill: parent + sourceComponent: root.textShare ? postPreview : imagePreview + } + + Component { + id: imagePreview + + ShareFilePreview { + shareAction: root.shareAction + metadataStripped: true + descriptionPlaceholderText: qsTr("Write a post") + } + } + + Component { + id: postPreview + + SilicaFlickable { + id: postRoot + + width: parent.width + height: contentHeight + contentHeight: contentColumn.height + + Component.onCompleted: { + sailfishTransfer.loadConfiguration(root.shareAction.toConfiguration()) + statusTextField.forceActiveFocus() + statusTextField.cursorPosition = statusTextField.text.length + } + + SailfishTransfer { + id: sailfishTransfer + } + + Column { + id: contentColumn + + width: parent.width + + TextArea { + id: linkTextField + + width: parent.width + //% "Link" + label: qsTrId("sailfishshare-la-link") + placeholderText: label + visible: sailfishTransfer.content.type === "text/x-url" + text: sailfishTransfer.content.data || sailfishTransfer.content.status || "" + } + + TextArea { + id: statusTextField + + width: parent.width + //% "Status update" + label: qsTrId("sailfishshare-la-status_update") + placeholderText: label + text: { + var title = sailfishTransfer.content.name || sailfishTransfer.content.linkTitle || "" + if (linkTextField.visible) { + return title + } + var body = sailfishTransfer.content.data || sailfishTransfer.content.status || "" + if (title.length > 0 && body.length > 0) { + return title + ": " + body + } + return title + body + } + } + + SystemDialogIconButton { + id: postButton + + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width / 2 + iconSource: "image://theme/icon-m-share" + bottomPadding: Theme.paddingLarge + _showPress: false + + //: Post a social network account status update + //% "Post" + text: qsTrId("sailfishshare-la-post_status") + + onClicked: { + var status = statusTextField.text || "" + var link = linkTextField.visible ? (linkTextField.text || "") : "" + if (link.length > 0 && status.indexOf(link) === -1) { + status = status.length > 0 ? (status + "\n" + link) : link + } + + sailfishTransfer.userData = { + "accountId": sailfishTransfer.transferMethodInfo.accountId, + "status": status + } + sailfishTransfer.mimeType = linkTextField.visible ? "text/x-url" : "text/plain" + sailfishTransfer.start() + root.shareAction.done() + } + } + } + } + } +} diff --git a/transferengine-plugins/fediverseshareplugin/fediverseplugininfo.cpp b/transferengine-plugins/fediverseshareplugin/fediverseplugininfo.cpp new file mode 100644 index 0000000..4e04be1 --- /dev/null +++ b/transferengine-plugins/fediverseshareplugin/fediverseplugininfo.cpp @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#include "fediverseplugininfo.h" +#include "fediverseshareservicestatus.h" + +FediversePluginInfo::FediversePluginInfo() + : SharingPluginInfo() + , m_fediverseShareServiceStatus(new FediverseShareServiceStatus(this)) +{ + m_capabilities << QLatin1String("image/jpeg") + << QLatin1String("image/png") + << QLatin1String("video/mp4") + << QLatin1String("text/x-url") + << QLatin1String("text/plain"); + + connect(m_fediverseShareServiceStatus, &FediverseShareServiceStatus::serviceReady, + this, &FediversePluginInfo::serviceReady); + connect(m_fediverseShareServiceStatus, &FediverseShareServiceStatus::serviceError, + this, &FediversePluginInfo::infoError); +} + +FediversePluginInfo::~FediversePluginInfo() +{ +} + +QList FediversePluginInfo::info() const +{ + return m_info; +} + +void FediversePluginInfo::query() +{ + m_fediverseShareServiceStatus->queryStatus(FediverseShareServiceStatus::PassiveMode); +} + +void FediversePluginInfo::serviceReady() +{ + m_info.clear(); + + for (int i = 0; i < m_fediverseShareServiceStatus->count(); ++i) { + SharingMethodInfo info; + + const FediverseShareServiceStatus::AccountDetails details = m_fediverseShareServiceStatus->details(i); + info.setDisplayName(details.providerName); + info.setSubtitle(details.displayName); + info.setAccountId(details.accountId); + + info.setMethodId(QLatin1String("Fediverse")); + info.setMethodIcon(details.iconPath.isEmpty() + ? QLatin1String("image://theme/icon-l-fediverse") + : details.iconPath); + info.setShareUIPath(QLatin1String("/usr/share/nemo-transferengine/plugins/sharing/FediverseSharePost.qml")); + info.setCapabilities(m_capabilities); + m_info << info; + } + + emit infoReady(); +} diff --git a/transferengine-plugins/fediverseshareplugin/fediverseplugininfo.h b/transferengine-plugins/fediverseshareplugin/fediverseplugininfo.h new file mode 100644 index 0000000..fdd8fc6 --- /dev/null +++ b/transferengine-plugins/fediverseshareplugin/fediverseplugininfo.h @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#ifndef FEDIVERSEPLUGININFO_H +#define FEDIVERSEPLUGININFO_H + +#include +#include + +class FediverseShareServiceStatus; + +class FediversePluginInfo : public SharingPluginInfo +{ + Q_OBJECT + +public: + FediversePluginInfo(); + ~FediversePluginInfo(); + + QList info() const; + void query(); + +private Q_SLOTS: + void serviceReady(); + +private: + FediverseShareServiceStatus *m_fediverseShareServiceStatus; + QList m_info; + QStringList m_capabilities; +}; + +#endif // FEDIVERSEPLUGININFO_H diff --git a/transferengine-plugins/fediverseshareplugin/fediverseshareplugin.cpp b/transferengine-plugins/fediverseshareplugin/fediverseshareplugin.cpp new file mode 100644 index 0000000..18c9c7c --- /dev/null +++ b/transferengine-plugins/fediverseshareplugin/fediverseshareplugin.cpp @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#include "fediverseshareplugin.h" +#include "fediverseplugininfo.h" + +#include + +FediverseSharePlugin::FediverseSharePlugin() + : QObject(), SharingPluginInterface() +{ +} + +FediverseSharePlugin::~FediverseSharePlugin() +{ +} + +SharingPluginInfo *FediverseSharePlugin::infoObject() +{ + return new FediversePluginInfo; +} + +QString FediverseSharePlugin::pluginId() const +{ + return QLatin1String("Fediverse"); +} diff --git a/transferengine-plugins/fediverseshareplugin/fediverseshareplugin.h b/transferengine-plugins/fediverseshareplugin/fediverseshareplugin.h new file mode 100644 index 0000000..0eb7772 --- /dev/null +++ b/transferengine-plugins/fediverseshareplugin/fediverseshareplugin.h @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#ifndef FEDIVERSESHAREPLUGIN_H +#define FEDIVERSESHAREPLUGIN_H + +#include + +#include + +class Q_DECL_EXPORT FediverseSharePlugin : public QObject, public SharingPluginInterface +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.sailfishos.share.plugin.fediverse") + Q_INTERFACES(SharingPluginInterface) + +public: + FediverseSharePlugin(); + ~FediverseSharePlugin(); + + SharingPluginInfo *infoObject(); + QString pluginId() const; +}; + +#endif // FEDIVERSESHAREPLUGIN_H diff --git a/transferengine-plugins/fediverseshareplugin/fediverseshareplugin.pro b/transferengine-plugins/fediverseshareplugin/fediverseshareplugin.pro new file mode 100644 index 0000000..a085a30 --- /dev/null +++ b/transferengine-plugins/fediverseshareplugin/fediverseshareplugin.pro @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. +# +# SPDX-License-Identifier: BSD-3-Clause + +TEMPLATE = lib +TARGET = $$qtLibraryTarget(fediverseshareplugin) +CONFIG += plugin +DEPENDPATH += . +INCLUDEPATH += .. +INCLUDEPATH += ../../common + +QT -= gui + +CONFIG += link_pkgconfig +PKGCONFIG += nemotransferengine-qt5 accounts-qt5 sailfishaccounts libsignon-qt5 + +HEADERS += fediverseshareplugin.h \ + fediverseplugininfo.h \ + ../fediverseshareservicestatus.h + +SOURCES += fediverseshareplugin.cpp \ + fediverseplugininfo.cpp \ + ../fediverseshareservicestatus.cpp + +target.path = $$[QT_INSTALL_LIBS]/nemo-transferengine/plugins/sharing + +OTHER_FILES += *.qml + +shareui.files = FediverseSharePost.qml +shareui.path = /usr/share/nemo-transferengine/plugins/sharing + +INSTALLS += target shareui diff --git a/transferengine-plugins/fediverseshareservicestatus.cpp b/transferengine-plugins/fediverseshareservicestatus.cpp new file mode 100644 index 0000000..0ab1460 --- /dev/null +++ b/transferengine-plugins/fediverseshareservicestatus.cpp @@ -0,0 +1,305 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#include "fediverseshareservicestatus.h" +#include "fediverseauthutils.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +FediverseShareServiceStatus::FediverseShareServiceStatus(QObject *parent) + : QObject(parent) + , m_auth(new AccountAuthenticator(this)) + , m_accountManager(new Accounts::Manager(this)) + , m_serviceName(QStringLiteral("fediverse-sharing")) +{ +} + +QString FediverseShareServiceStatus::authServiceName() const +{ + return QStringLiteral("fediverse-microblog"); +} + +void FediverseShareServiceStatus::signIn(int accountId) +{ + Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this); + if (!account) { + qWarning() << Q_FUNC_INFO << "Failed to retrieve account for id:" << accountId; + setAccountDetailsState(accountId, Error); + return; + } + + const Accounts::Service service(m_accountManager->service(authServiceName())); + if (!service.isValid()) { + qWarning() << Q_FUNC_INFO << "Invalid auth service" << authServiceName(); + account->deleteLater(); + setAccountDetailsState(accountId, Error); + return; + } + + account->selectService(service); + + SignOn::Identity *identity = account->credentialsId() > 0 + ? SignOn::Identity::existingIdentity(account->credentialsId()) + : 0; + if (!identity) { + qWarning() << Q_FUNC_INFO << "account" << accountId << "has no valid credentials"; + account->deleteLater(); + setAccountDetailsState(accountId, Error); + return; + } + + Accounts::AccountService accountService(account, service); + const QString method = accountService.authData().method(); + const QString mechanism = accountService.authData().mechanism(); + + SignOn::AuthSession *session = identity->createSession(method); + if (!session) { + qWarning() << Q_FUNC_INFO << "could not create signon session for account" << accountId; + identity->deleteLater(); + account->deleteLater(); + setAccountDetailsState(accountId, Error); + return; + } + + QVariantMap signonSessionData = accountService.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(account)); + session->setProperty("identity", QVariant::fromValue(identity)); + session->process(SignOn::SessionData(signonSessionData), mechanism); +} + +void FediverseShareServiceStatus::signOnResponse(const SignOn::SessionData &responseData) +{ + const QVariantMap data = FediverseAuthUtils::responseDataToMap(responseData); + + SignOn::AuthSession *session = qobject_cast(sender()); + Accounts::Account *account = session->property("account").value(); + SignOn::Identity *identity = session->property("identity").value(); + const int accountId = account ? account->id() : 0; + + QString accessToken = FediverseAuthUtils::accessToken(data); + + if (accountId > 0 && m_accountIdToDetailsIdx.contains(accountId)) { + AccountDetails &accountDetails(m_accountDetails[m_accountIdToDetailsIdx[accountId]]); + accountDetails.accessToken = accessToken; + setAccountDetailsState(accountId, accessToken.isEmpty() ? Error : Populated); + } + + session->disconnect(this); + if (identity) { + identity->destroySession(session); + identity->deleteLater(); + } + if (account) { + account->deleteLater(); + } +} + +void FediverseShareServiceStatus::signOnError(const SignOn::Error &error) +{ + SignOn::AuthSession *session = qobject_cast(sender()); + Accounts::Account *account = session->property("account").value(); + SignOn::Identity *identity = session->property("identity").value(); + const int accountId = account ? account->id() : 0; + + qWarning() << Q_FUNC_INFO << "failed to retrieve credentials for account" << accountId + << error.type() << error.message(); + + if (accountId > 0 && error.type() == SignOn::Error::UserInteraction) { + setCredentialsNeedUpdate(accountId, authServiceName()); + } + + session->disconnect(this); + if (identity) { + identity->destroySession(session); + identity->deleteLater(); + } + if (account) { + account->deleteLater(); + } + + if (accountId > 0) { + setAccountDetailsState(accountId, Error); + } +} + +void FediverseShareServiceStatus::setAccountDetailsState(int accountId, AccountDetailsState state) +{ + if (!m_accountIdToDetailsIdx.contains(accountId)) { + return; + } + + m_accountDetailsState[accountId] = state; + + bool anyWaiting = false; + bool anyPopulated = false; + Q_FOREACH (int id, m_accountDetailsState.keys()) { + AccountDetailsState accountState = m_accountDetailsState.value(id, Waiting); + if (accountState == Waiting) { + anyWaiting = true; + } else if (accountState == Populated) { + anyPopulated = true; + } + } + + if (!anyWaiting) { + if (anyPopulated) { + emit serviceReady(); + } else { + emit serviceError(QStringLiteral("Unable to retrieve Fediverse account credentials")); + } + } +} + +int FediverseShareServiceStatus::count() const +{ + return m_accountDetails.count(); +} + +bool FediverseShareServiceStatus::setCredentialsNeedUpdate(int accountId, const QString &serviceName) +{ + return m_auth->setCredentialsNeedUpdate(accountId, serviceName); +} + +void FediverseShareServiceStatus::queryStatus(QueryStatusMode mode) +{ + m_accountDetails.clear(); + m_accountIdToDetailsIdx.clear(); + m_accountDetailsState.clear(); + + bool signInActive = false; + Q_FOREACH (Accounts::AccountId id, m_accountManager->accountList()) { + Accounts::Account *acc = Accounts::Account::fromId(m_accountManager, id, this); + + if (!acc) { + qWarning() << Q_FUNC_INFO << "Failed to get account for id:" << id; + continue; + } + + acc->selectService(Accounts::Service()); + + const Accounts::Service service(m_accountManager->service(m_serviceName)); + const Accounts::ServiceList services = acc->services(); + bool serviceFound = false; + Q_FOREACH (const Accounts::Service &s, services) { + if (s.name() == m_serviceName) { + serviceFound = true; + break; + } + } + + if (!service.isValid() || !serviceFound) { + acc->deleteLater(); + continue; + } + + const bool accountEnabled = acc->enabled(); + acc->selectService(service); + const bool shareServiceEnabled = acc->enabled(); + if (!accountEnabled || !shareServiceEnabled) { + acc->selectService(Accounts::Service()); + acc->deleteLater(); + continue; + } + + if (acc->value(QStringLiteral("CredentialsNeedUpdate")).toBool()) { + qWarning() << Q_FUNC_INFO << "Credentials need update for account id:" << id; + acc->selectService(Accounts::Service()); + acc->deleteLater(); + continue; + } + + if (!m_accountIdToDetailsIdx.contains(id)) { + AccountDetails details; + details.accountId = id; + acc->selectService(Accounts::Service()); + details.apiHost = FediverseAuthUtils::normalizeApiHost(acc->value(QStringLiteral("api/Host")).toString()); + const QString instanceTitle = acc->value(QStringLiteral("instance/Title")).toString().trimmed(); + details.iconPath = acc->value(QStringLiteral("iconPath")).toString().trimmed(); + acc->selectService(service); + + QUrl apiUrl(details.apiHost); + details.providerName = instanceTitle; + if (details.providerName.isEmpty()) { + details.providerName = apiUrl.host(); + } + if (details.providerName.isEmpty()) { + details.providerName = details.apiHost; + if (details.providerName.startsWith(QLatin1String("https://"))) { + details.providerName.remove(0, 8); + } else if (details.providerName.startsWith(QLatin1String("http://"))) { + details.providerName.remove(0, 7); + } + const int separator = details.providerName.indexOf(QLatin1Char('/')); + if (separator > 0) { + details.providerName.truncate(separator); + } + } + + details.displayName = acc->value(QStringLiteral("description")).toString().trimmed(); + if (details.displayName.isEmpty()) { + details.displayName = acc->displayName(); + } + + m_accountIdToDetailsIdx.insert(id, m_accountDetails.size()); + m_accountDetails.append(details); + } + + if (mode == SignInMode) { + signInActive = true; + m_accountDetailsState.insert(id, Waiting); + signIn(id); + } + + acc->selectService(Accounts::Service()); + acc->deleteLater(); + } + + if (!signInActive) { + emit serviceReady(); + } +} + +FediverseShareServiceStatus::AccountDetails FediverseShareServiceStatus::details(int index) const +{ + if (index < 0 || index >= m_accountDetails.size()) { + qWarning() << Q_FUNC_INFO << "Index out of range"; + return AccountDetails(); + } + + return m_accountDetails.at(index); +} + +FediverseShareServiceStatus::AccountDetails FediverseShareServiceStatus::detailsByIdentifier(int accountIdentifier) const +{ + if (!m_accountIdToDetailsIdx.contains(accountIdentifier)) { + qWarning() << Q_FUNC_INFO << "No details known for account with identifier" << accountIdentifier; + return AccountDetails(); + } + + return m_accountDetails[m_accountIdToDetailsIdx[accountIdentifier]]; +} diff --git a/transferengine-plugins/fediverseshareservicestatus.h b/transferengine-plugins/fediverseshareservicestatus.h new file mode 100644 index 0000000..a82be17 --- /dev/null +++ b/transferengine-plugins/fediverseshareservicestatus.h @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#ifndef FEDIVERSESHARESERVICESTATUS_H +#define FEDIVERSESHARESERVICESTATUS_H + +#include +#include +#include + +#include + +namespace Accounts { +class Account; +class Manager; +} + +namespace SignOn { +class Error; +class SessionData; +} + +class FediverseShareServiceStatus : public QObject +{ + Q_OBJECT + +public: + explicit FediverseShareServiceStatus(QObject *parent = 0); + + enum QueryStatusMode { + PassiveMode = 0, + SignInMode = 1 + }; + + void queryStatus(QueryStatusMode mode = SignInMode); + + struct AccountDetails { + int accountId = 0; + QString providerName; + QString displayName; + QString accessToken; + QString apiHost; + QString iconPath; + }; + + AccountDetails details(int index = 0) const; + AccountDetails detailsByIdentifier(int accountIdentifier) const; + int count() const; + + bool setCredentialsNeedUpdate(int accountId, const QString &serviceName); + +Q_SIGNALS: + void serviceReady(); + void serviceError(const QString &message); + +private Q_SLOTS: + void signOnResponse(const SignOn::SessionData &responseData); + void signOnError(const SignOn::Error &error); + +private: + enum AccountDetailsState { + Waiting, + Populated, + Error + }; + + QString authServiceName() const; + void setAccountDetailsState(int accountId, AccountDetailsState state); + void signIn(int accountId); + + AccountAuthenticator *m_auth; + Accounts::Manager *m_accountManager; + QString m_serviceName; + QVector m_accountDetails; + QHash m_accountIdToDetailsIdx; + QHash m_accountDetailsState; +}; + +#endif // FEDIVERSESHARESERVICESTATUS_H diff --git a/transferengine-plugins/fediversetransferplugin/fediverseapi.cpp b/transferengine-plugins/fediversetransferplugin/fediverseapi.cpp new file mode 100644 index 0000000..d9de9eb --- /dev/null +++ b/transferengine-plugins/fediversetransferplugin/fediverseapi.cpp @@ -0,0 +1,255 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#include "fediverseapi.h" +#include "fediverseauthutils.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +FediverseApi::FediverseApi(QNetworkAccessManager *qnam, QObject *parent) + : QObject(parent) + , m_cancelRequested(false) + , m_qnam(qnam) +{ +} + +FediverseApi::~FediverseApi() +{ +} + +bool FediverseApi::uploadImage(const QString &filePath, + const QString &statusText, + const QString &mimeType, + const QString &apiHost, + const QString &accessToken) +{ + QFile file(filePath); + if (filePath.isEmpty() || !file.open(QIODevice::ReadOnly)) { + qWarning() << Q_FUNC_INFO << "error opening file:" << filePath; + return false; + } + + m_cancelRequested = false; + m_apiHost = FediverseAuthUtils::normalizeApiHost(apiHost); + m_accessToken = accessToken; + m_statusText = statusText; + + if (m_accessToken.isEmpty()) { + qWarning() << Q_FUNC_INFO << "missing access token"; + return false; + } + + const QByteArray imageData = file.readAll(); + const QFileInfo fileInfo(filePath); + + QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); + + QHttpPart filePart; + filePart.setHeader(QNetworkRequest::ContentDispositionHeader, + QVariant(QStringLiteral("form-data; name=\"file\"; filename=\"%1\"") + .arg(fileInfo.fileName()))); + if (!mimeType.isEmpty()) { + filePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(mimeType)); + } + filePart.setBody(imageData); + multiPart->append(filePart); + + QNetworkRequest request(QUrl(m_apiHost + QStringLiteral("/api/v1/media"))); + request.setRawHeader(QByteArrayLiteral("Authorization"), + QByteArrayLiteral("Bearer ") + m_accessToken.toUtf8()); + + QNetworkReply *reply = m_qnam->post(request, multiPart); + if (!reply) { + delete multiPart; + return false; + } + + multiPart->setParent(reply); + m_replies.insert(reply, UPLOAD_MEDIA); + + connect(reply, SIGNAL(error(QNetworkReply::NetworkError)), + this, SLOT(replyError(QNetworkReply::NetworkError))); + connect(reply, &QNetworkReply::uploadProgress, + this, &FediverseApi::uploadProgress); + connect(reply, &QNetworkReply::finished, + this, &FediverseApi::finished); + + return true; +} + +bool FediverseApi::postStatus(const QString &statusText, + const QString &apiHost, + const QString &accessToken) +{ + m_cancelRequested = false; + m_apiHost = FediverseAuthUtils::normalizeApiHost(apiHost); + m_accessToken = accessToken; + m_statusText = statusText; + + if (m_accessToken.isEmpty()) { + qWarning() << Q_FUNC_INFO << "missing access token"; + return false; + } + + return postStatusInternal(QString()); +} + +bool FediverseApi::postStatusInternal(const QString &mediaId) +{ + if (m_statusText.trimmed().isEmpty() && mediaId.isEmpty()) { + qWarning() << Q_FUNC_INFO << "status and media id are empty"; + return false; + } + + QUrlQuery query; + if (!m_statusText.isEmpty()) { + query.addQueryItem(QStringLiteral("status"), m_statusText); + } + if (!mediaId.isEmpty()) { + query.addQueryItem(QStringLiteral("media_ids[]"), mediaId); + } + + const QByteArray postData = query.query(QUrl::FullyEncoded).toUtf8(); + + QNetworkRequest request(QUrl(m_apiHost + QStringLiteral("/api/v1/statuses"))); + request.setRawHeader(QByteArrayLiteral("Authorization"), + QByteArrayLiteral("Bearer ") + m_accessToken.toUtf8()); + request.setHeader(QNetworkRequest::ContentTypeHeader, + QVariant(QStringLiteral("application/x-www-form-urlencoded"))); + + QNetworkReply *reply = m_qnam->post(request, postData); + if (!reply) { + return false; + } + + m_replies.insert(reply, POST_STATUS); + connect(reply, SIGNAL(error(QNetworkReply::NetworkError)), + this, SLOT(replyError(QNetworkReply::NetworkError))); + connect(reply, &QNetworkReply::finished, + this, &FediverseApi::finished); + + return true; +} + +void FediverseApi::cancelUpload() +{ + if (m_replies.isEmpty()) { + qWarning() << Q_FUNC_INFO << "can't cancel upload"; + return; + } + + m_cancelRequested = true; + const QList replies = m_replies.keys(); + Q_FOREACH (QNetworkReply *reply, replies) { + reply->abort(); + } +} + +void FediverseApi::replyError(QNetworkReply::NetworkError error) +{ + Q_UNUSED(error) +} + +void FediverseApi::uploadProgress(qint64 sent, qint64 total) +{ + if (total > 0) { + emit transferProgressUpdated(sent / static_cast(total)); + } +} + +void FediverseApi::finished() +{ + QNetworkReply *reply = qobject_cast(sender()); + if (!reply || !m_replies.contains(reply)) { + return; + } + + const API_CALL apiCall = m_replies.take(reply); + const QByteArray data = reply->readAll(); + const int httpCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QNetworkReply::NetworkError error = reply->error(); + + reply->deleteLater(); + + if (m_cancelRequested && error == QNetworkReply::OperationCanceledError) { + if (m_replies.isEmpty()) { + m_cancelRequested = false; + emit transferCanceled(); + } + return; + } + + if (apiCall == UPLOAD_MEDIA) { + if (error != QNetworkReply::NoError || httpCode < 200 || httpCode >= 300) { + finishTransfer(error == QNetworkReply::NoError ? QNetworkReply::UnknownNetworkError : error, + httpCode, + data); + return; + } + + QString mediaId; + const QJsonDocument doc = QJsonDocument::fromJson(data); + if (doc.isObject()) { + const QJsonValue idValue = doc.object().value(QStringLiteral("id")); + if (idValue.isString()) { + mediaId = idValue.toString(); + } else if (idValue.isDouble()) { + mediaId = QString::number(static_cast(idValue.toDouble())); + } + } + + if (!postStatusInternal(mediaId)) { + qWarning() << Q_FUNC_INFO << "unable to create fediverse status"; + emit transferError(); + } + return; + } + + if (apiCall == POST_STATUS) { + finishTransfer(error, httpCode, data); + return; + } + + emit transferError(); +} + +void FediverseApi::finishTransfer(QNetworkReply::NetworkError error, int httpCode, const QByteArray &data) +{ + m_cancelRequested = false; + + if (httpCode == 401) { + emit credentialsExpired(); + } + + if (error != QNetworkReply::NoError) { + if (error == QNetworkReply::OperationCanceledError) { + emit transferCanceled(); + return; + } + + qWarning() << Q_FUNC_INFO << "network error:" << error << "httpCode:" << httpCode << "data:" << data; + emit transferError(); + return; + } + + if (httpCode < 200 || httpCode >= 300) { + qWarning() << Q_FUNC_INFO << "http error:" << httpCode << "data:" << data; + emit transferError(); + return; + } + + emit transferFinished(); +} diff --git a/transferengine-plugins/fediversetransferplugin/fediverseapi.h b/transferengine-plugins/fediversetransferplugin/fediverseapi.h new file mode 100644 index 0000000..a85442c --- /dev/null +++ b/transferengine-plugins/fediversetransferplugin/fediverseapi.h @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#ifndef FEDIVERSEAPI_H +#define FEDIVERSEAPI_H + +#include +#include + +#include +#include + +class FediverseApi : public QObject +{ + Q_OBJECT + +public: + enum API_CALL { + NONE, + UPLOAD_MEDIA, + POST_STATUS + }; + + explicit FediverseApi(QNetworkAccessManager *qnam, QObject *parent = 0); + ~FediverseApi(); + + bool uploadImage(const QString &filePath, + const QString &statusText, + const QString &mimeType, + const QString &apiHost, + const QString &accessToken); + bool postStatus(const QString &statusText, + const QString &apiHost, + const QString &accessToken); + + void cancelUpload(); + +Q_SIGNALS: + void transferProgressUpdated(qreal progress); + void transferFinished(); + void transferError(); + void transferCanceled(); + void credentialsExpired(); + +private Q_SLOTS: + void replyError(QNetworkReply::NetworkError error); + void finished(); + void uploadProgress(qint64 received, qint64 total); + +private: + bool postStatusInternal(const QString &mediaId); + void finishTransfer(QNetworkReply::NetworkError error, int httpCode, const QByteArray &data); + + QMap m_replies; + bool m_cancelRequested; + QNetworkAccessManager *m_qnam; + QString m_accessToken; + QString m_apiHost; + QString m_statusText; +}; + +#endif // FEDIVERSEAPI_H diff --git a/transferengine-plugins/fediversetransferplugin/fediversetransferplugin.cpp b/transferengine-plugins/fediversetransferplugin/fediversetransferplugin.cpp new file mode 100644 index 0000000..bd213f8 --- /dev/null +++ b/transferengine-plugins/fediversetransferplugin/fediversetransferplugin.cpp @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#include "fediversetransferplugin.h" +#include "fediverseuploader.h" + +#include +#include + +FediverseTransferPlugin::FediverseTransferPlugin() + : QObject(), TransferPluginInterface() + , m_qnam(new QNetworkAccessManager(this)) +{ +} + +FediverseTransferPlugin::~FediverseTransferPlugin() +{ +} + +MediaTransferInterface *FediverseTransferPlugin::transferObject() +{ + return new FediverseUploader(m_qnam, this); +} + +QString FediverseTransferPlugin::pluginId() const +{ + return QLatin1String("Fediverse"); +} diff --git a/transferengine-plugins/fediversetransferplugin/fediversetransferplugin.h b/transferengine-plugins/fediversetransferplugin/fediversetransferplugin.h new file mode 100644 index 0000000..163d23f --- /dev/null +++ b/transferengine-plugins/fediversetransferplugin/fediversetransferplugin.h @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#ifndef FEDIVERSETRANSFERPLUGIN_H +#define FEDIVERSETRANSFERPLUGIN_H + +#include + +#include + +class QNetworkAccessManager; + +class Q_DECL_EXPORT FediverseTransferPlugin : public QObject, public TransferPluginInterface +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.sailfishos.transfer.plugin.fediverse") + Q_INTERFACES(TransferPluginInterface) + +public: + FediverseTransferPlugin(); + ~FediverseTransferPlugin(); + + MediaTransferInterface *transferObject(); + QString pluginId() const; + +private: + QNetworkAccessManager *m_qnam; +}; + +#endif // FEDIVERSETRANSFERPLUGIN_H diff --git a/transferengine-plugins/fediversetransferplugin/fediversetransferplugin.pro b/transferengine-plugins/fediversetransferplugin/fediversetransferplugin.pro new file mode 100644 index 0000000..8451dc5 --- /dev/null +++ b/transferengine-plugins/fediversetransferplugin/fediversetransferplugin.pro @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. +# +# SPDX-License-Identifier: BSD-3-Clause + +TEMPLATE = lib +TARGET = $$qtLibraryTarget(fediversetransferplugin) +CONFIG += plugin +DEPENDPATH += . +INCLUDEPATH += .. +INCLUDEPATH += ../../common + +QT -= gui +QT += network + +CONFIG += link_pkgconfig +PKGCONFIG += nemotransferengine-qt5 accounts-qt5 sailfishaccounts libsignon-qt5 + +HEADERS += fediversetransferplugin.h \ + fediverseuploader.h \ + ../fediverseshareservicestatus.h \ + fediverseapi.h + +SOURCES += fediversetransferplugin.cpp \ + fediverseuploader.cpp \ + ../fediverseshareservicestatus.cpp \ + fediverseapi.cpp + +target.path = $$[QT_INSTALL_LIBS]/nemo-transferengine/plugins/transfer + +INSTALLS += target diff --git a/transferengine-plugins/fediversetransferplugin/fediverseuploader.cpp b/transferengine-plugins/fediversetransferplugin/fediverseuploader.cpp new file mode 100644 index 0000000..7c8766b --- /dev/null +++ b/transferengine-plugins/fediversetransferplugin/fediverseuploader.cpp @@ -0,0 +1,252 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#include "fediverseuploader.h" +#include "fediverseapi.h" + +#include +#include + +#include +#include +#include + +#include + +FediverseUploader::FediverseUploader(QNetworkAccessManager *qnam, QObject *parent) + : MediaTransferInterface(parent) + , m_api(0) + , m_fediverseShareServiceStatus(0) + , m_qnam(qnam) + , m_useTmpFile(false) +{ +} + +FediverseUploader::~FediverseUploader() +{ +} + +QString FediverseUploader::displayName() const +{ + return tr("Fediverse"); +} + +QUrl FediverseUploader::serviceIcon() const +{ + return QUrl(QStringLiteral("image://theme/icon-l-fediverse")); +} + +bool FediverseUploader::cancelEnabled() const +{ + return true; +} + +bool FediverseUploader::restartEnabled() const +{ + return true; +} + +void FediverseUploader::start() +{ + if (!mediaItem()) { + qWarning() << Q_FUNC_INFO << "NULL MediaItem. Can't continue"; + setStatus(MediaTransferInterface::TransferInterrupted); + return; + } + + if (!m_fediverseShareServiceStatus) { + m_fediverseShareServiceStatus = new FediverseShareServiceStatus(this); + connect(m_fediverseShareServiceStatus, &FediverseShareServiceStatus::serviceReady, + this, &FediverseUploader::startUploading); + connect(m_fediverseShareServiceStatus, &FediverseShareServiceStatus::serviceError, + this, [this] (const QString &) { + transferError(); + }); + } + + m_fediverseShareServiceStatus->queryStatus(); +} + +void FediverseUploader::cancel() +{ + if (m_api) { + m_api->cancelUpload(); + } else { + qWarning() << Q_FUNC_INFO << "Can't cancel. NULL FediverseApi object!"; + } +} + +void FediverseUploader::startUploading() +{ + if (!m_fediverseShareServiceStatus) { + qWarning() << Q_FUNC_INFO << "NULL FediverseShareServiceStatus object!"; + return; + } + + const quint32 accountId = mediaItem()->value(MediaItem::AccountId).toInt(); + m_accountDetails = m_fediverseShareServiceStatus->detailsByIdentifier(accountId); + if (m_accountDetails.accountId <= 0 || m_accountDetails.accessToken.isEmpty()) { + qWarning() << Q_FUNC_INFO << "Fediverse account details missing for id" << accountId; + transferError(); + return; + } + + const QString mimeType = mediaItem()->value(MediaItem::MimeType).toString(); + if (mimeType.startsWith(QLatin1String("image/")) + || mimeType.startsWith(QLatin1String("video/"))) { + postImage(); + } else if (mimeType.contains(QLatin1String("text/plain")) + || mimeType.contains(QLatin1String("text/x-url"))) { + postStatus(); + } else { + qWarning() << Q_FUNC_INFO << "Unsupported mime type:" << mimeType; + setStatus(MediaTransferInterface::TransferInterrupted); + } +} + +void FediverseUploader::transferFinished() +{ + setStatus(MediaTransferInterface::TransferFinished); +} + +void FediverseUploader::transferProgress(qreal progress) +{ + setProgress(progress); +} + +void FediverseUploader::transferError() +{ + setStatus(MediaTransferInterface::TransferInterrupted); + qWarning() << Q_FUNC_INFO << "Transfer interrupted"; +} + +void FediverseUploader::transferCanceled() +{ + setStatus(MediaTransferInterface::TransferCanceled); +} + +void FediverseUploader::credentialsExpired() +{ + const quint32 accountId = mediaItem()->value(MediaItem::AccountId).toInt(); + m_fediverseShareServiceStatus->setCredentialsNeedUpdate(accountId, QStringLiteral("fediverse-sharing")); +} + +void FediverseUploader::setStatus(MediaTransferInterface::TransferStatus status) +{ + const bool finished = (status == TransferCanceled + || status == TransferInterrupted + || status == TransferFinished); + if (m_useTmpFile && finished) { + QFile::remove(m_filePath); + m_useTmpFile = false; + m_filePath.clear(); + } + + MediaTransferInterface::setStatus(status); +} + +void FediverseUploader::postImage() +{ + m_useTmpFile = false; + m_filePath.clear(); + const QString sourceFile = mediaItem()->value(MediaItem::Url).toUrl().toLocalFile(); + if (sourceFile.isEmpty()) { + qWarning() << Q_FUNC_INFO << "Empty source file"; + setStatus(MediaTransferInterface::TransferInterrupted); + return; + } + + QMimeDatabase db; + const QMimeType mime = db.mimeTypeForFile(sourceFile); + const bool isImage = mediaItem()->value(MediaItem::MimeType).toString().startsWith(QLatin1String("image/")); + const bool isJpeg = isImage && mime.name() == QLatin1String("image/jpeg"); + + if (isJpeg && mediaItem()->value(MediaItem::MetadataStripped).toBool()) { + m_useTmpFile = true; + m_filePath = ImageOperation::removeImageMetadata(sourceFile); + if (m_filePath.isEmpty()) { + qWarning() << Q_FUNC_INFO << "Failed to remove metadata"; + MediaTransferInterface::setStatus(MediaTransferInterface::TransferInterrupted); + return; + } + } + + const qreal scale = mediaItem()->value(MediaItem::ScalePercent).toReal(); + if (isImage && 0 < scale && scale < 1) { + m_useTmpFile = true; + m_filePath = ImageOperation::scaleImage(sourceFile, scale, m_filePath); + if (m_filePath.isEmpty()) { + qWarning() << Q_FUNC_INFO << "Failed to scale image"; + MediaTransferInterface::setStatus(MediaTransferInterface::TransferInterrupted); + return; + } + } + + if (!m_useTmpFile) { + m_filePath = sourceFile; + } + + ensureApi(); + + const bool ok = m_api->uploadImage(m_filePath, + mediaItem()->value(MediaItem::Description).toString(), + mediaItem()->value(MediaItem::MimeType).toString(), + m_accountDetails.apiHost, + m_accountDetails.accessToken); + if (ok) { + setStatus(MediaTransferInterface::TransferStarted); + } else { + setStatus(MediaTransferInterface::TransferInterrupted); + qWarning() << Q_FUNC_INFO << "Failed to upload media"; + } +} + +void FediverseUploader::postStatus() +{ + ensureApi(); + + const QVariantMap userData = mediaItem()->value(MediaItem::UserData).toMap(); + QString statusText = userData.value(QStringLiteral("status")).toString().trimmed(); + if (statusText.isEmpty()) { + statusText = mediaItem()->value(MediaItem::Description).toString().trimmed(); + } + if (statusText.isEmpty()) { + statusText = mediaItem()->value(MediaItem::ContentData).toString().trimmed(); + } + + if (statusText.isEmpty()) { + qWarning() << Q_FUNC_INFO << "Failed to resolve status text"; + setStatus(MediaTransferInterface::TransferInterrupted); + return; + } + + const bool ok = m_api->postStatus(statusText, + m_accountDetails.apiHost, + m_accountDetails.accessToken); + if (ok) { + setStatus(MediaTransferInterface::TransferStarted); + } else { + setStatus(MediaTransferInterface::TransferInterrupted); + qWarning() << Q_FUNC_INFO << "Failed to post status"; + } +} + +void FediverseUploader::ensureApi() +{ + if (!m_api) { + m_api = new FediverseApi(m_qnam, this); + connect(m_api, &FediverseApi::transferProgressUpdated, + this, &FediverseUploader::transferProgress); + connect(m_api, &FediverseApi::transferFinished, + this, &FediverseUploader::transferFinished); + connect(m_api, &FediverseApi::transferError, + this, &FediverseUploader::transferError); + connect(m_api, &FediverseApi::transferCanceled, + this, &FediverseUploader::transferCanceled); + connect(m_api, &FediverseApi::credentialsExpired, + this, &FediverseUploader::credentialsExpired); + } +} diff --git a/transferengine-plugins/fediversetransferplugin/fediverseuploader.h b/transferengine-plugins/fediversetransferplugin/fediverseuploader.h new file mode 100644 index 0000000..2343145 --- /dev/null +++ b/transferengine-plugins/fediversetransferplugin/fediverseuploader.h @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +#ifndef FEDIVERSEUPLOADER_H +#define FEDIVERSEUPLOADER_H + +#include + +#include + +#include "fediverseshareservicestatus.h" + +class FediverseApi; + +class FediverseUploader : public MediaTransferInterface +{ + Q_OBJECT + +public: + FediverseUploader(QNetworkAccessManager *qnam, QObject *parent = 0); + ~FediverseUploader(); + + QString displayName() const; + QUrl serviceIcon() const; + bool cancelEnabled() const; + bool restartEnabled() const; + +public Q_SLOTS: + void start(); + void cancel(); + +private Q_SLOTS: + void startUploading(); + void transferFinished(); + void transferProgress(qreal progress); + void transferError(); + void transferCanceled(); + void credentialsExpired(); + +protected: + void setStatus(MediaTransferInterface::TransferStatus status); + +private: + void ensureApi(); + void postImage(); + void postStatus(); + + FediverseApi *m_api; + FediverseShareServiceStatus *m_fediverseShareServiceStatus; + QNetworkAccessManager *m_qnam; + FediverseShareServiceStatus::AccountDetails m_accountDetails; + bool m_useTmpFile; + QString m_filePath; +}; + +#endif // FEDIVERSEUPLOADER_H diff --git a/transferengine-plugins/mastodonshareplugin/MastodonSharePost.qml b/transferengine-plugins/mastodonshareplugin/MastodonSharePost.qml deleted file mode 100644 index d859d96..0000000 --- a/transferengine-plugins/mastodonshareplugin/MastodonSharePost.qml +++ /dev/null @@ -1,138 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -import QtQuick 2.6 -import Sailfish.Silica 1.0 -import Sailfish.Lipstick 1.0 -import Sailfish.TransferEngine 1.0 - -Item { - id: root - - property var shareAction - property string mimeType: { - if (shareAction && shareAction.mimeType) { - return shareAction.mimeType - } - if (shareAction && shareAction.resources - && shareAction.resources.length > 0 - && shareAction.resources[0] - && shareAction.resources[0].type) { - return shareAction.resources[0].type - } - return "" - } - property bool textShare: mimeType === "text/x-url" || mimeType === "text/plain" - - width: parent ? parent.width : 0 - height: previewLoader.item ? previewLoader.item.height : 0 - - Loader { - id: previewLoader - - anchors.fill: parent - sourceComponent: root.textShare ? postPreview : imagePreview - } - - Component { - id: imagePreview - - ShareFilePreview { - shareAction: root.shareAction - metadataStripped: true - descriptionPlaceholderText: qsTr("Write a post") - } - } - - Component { - id: postPreview - - SilicaFlickable { - id: postRoot - - width: parent.width - height: contentHeight - contentHeight: contentColumn.height - - Component.onCompleted: { - sailfishTransfer.loadConfiguration(root.shareAction.toConfiguration()) - statusTextField.forceActiveFocus() - statusTextField.cursorPosition = statusTextField.text.length - } - - SailfishTransfer { - id: sailfishTransfer - } - - Column { - id: contentColumn - - width: parent.width - - TextArea { - id: linkTextField - - width: parent.width - //% "Link" - label: qsTrId("sailfishshare-la-link") - placeholderText: label - visible: sailfishTransfer.content.type === "text/x-url" - text: sailfishTransfer.content.data || sailfishTransfer.content.status || "" - } - - TextArea { - id: statusTextField - - width: parent.width - //% "Status update" - label: qsTrId("sailfishshare-la-status_update") - placeholderText: label - text: { - var title = sailfishTransfer.content.name || sailfishTransfer.content.linkTitle || "" - if (linkTextField.visible) { - return title - } - var body = sailfishTransfer.content.data || sailfishTransfer.content.status || "" - if (title.length > 0 && body.length > 0) { - return title + ": " + body - } - return title + body - } - } - - SystemDialogIconButton { - id: postButton - - anchors.horizontalCenter: parent.horizontalCenter - width: parent.width / 2 - iconSource: "image://theme/icon-m-share" - bottomPadding: Theme.paddingLarge - _showPress: false - - //: Post a social network account status update - //% "Post" - text: qsTrId("sailfishshare-la-post_status") - - onClicked: { - var status = statusTextField.text || "" - var link = linkTextField.visible ? (linkTextField.text || "") : "" - if (link.length > 0 && status.indexOf(link) === -1) { - status = status.length > 0 ? (status + "\n" + link) : link - } - - sailfishTransfer.userData = { - "accountId": sailfishTransfer.transferMethodInfo.accountId, - "status": status - } - sailfishTransfer.mimeType = linkTextField.visible ? "text/x-url" : "text/plain" - sailfishTransfer.start() - root.shareAction.done() - } - } - } - } - } -} diff --git a/transferengine-plugins/mastodonshareplugin/mastodonplugininfo.cpp b/transferengine-plugins/mastodonshareplugin/mastodonplugininfo.cpp deleted file mode 100644 index 919d544..0000000 --- a/transferengine-plugins/mastodonshareplugin/mastodonplugininfo.cpp +++ /dev/null @@ -1,60 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -#include "mastodonplugininfo.h" -#include "mastodonshareservicestatus.h" - -MastodonPluginInfo::MastodonPluginInfo() - : SharingPluginInfo() - , m_mastodonShareServiceStatus(new MastodonShareServiceStatus(this)) -{ - m_capabilities << QLatin1String("image/jpeg") - << QLatin1String("image/png") - << QLatin1String("video/mp4") - << QLatin1String("text/x-url") - << QLatin1String("text/plain"); - - connect(m_mastodonShareServiceStatus, &MastodonShareServiceStatus::serviceReady, - this, &MastodonPluginInfo::serviceReady); - connect(m_mastodonShareServiceStatus, &MastodonShareServiceStatus::serviceError, - this, &MastodonPluginInfo::infoError); -} - -MastodonPluginInfo::~MastodonPluginInfo() -{ -} - -QList MastodonPluginInfo::info() const -{ - return m_info; -} - -void MastodonPluginInfo::query() -{ - m_mastodonShareServiceStatus->queryStatus(MastodonShareServiceStatus::PassiveMode); -} - -void MastodonPluginInfo::serviceReady() -{ - m_info.clear(); - - for (int i = 0; i < m_mastodonShareServiceStatus->count(); ++i) { - SharingMethodInfo info; - - const MastodonShareServiceStatus::AccountDetails details = m_mastodonShareServiceStatus->details(i); - info.setDisplayName(details.providerName); - info.setSubtitle(details.displayName); - info.setAccountId(details.accountId); - - info.setMethodId(QLatin1String("Mastodon")); - info.setMethodIcon(QLatin1String("image://theme/icon-l-mastodon")); - info.setShareUIPath(QLatin1String("/usr/share/nemo-transferengine/plugins/sharing/MastodonSharePost.qml")); - info.setCapabilities(m_capabilities); - m_info << info; - } - - emit infoReady(); -} diff --git a/transferengine-plugins/mastodonshareplugin/mastodonplugininfo.h b/transferengine-plugins/mastodonshareplugin/mastodonplugininfo.h deleted file mode 100644 index 80fe552..0000000 --- a/transferengine-plugins/mastodonshareplugin/mastodonplugininfo.h +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -#ifndef MASTODONPLUGININFO_H -#define MASTODONPLUGININFO_H - -#include -#include - -class MastodonShareServiceStatus; - -class MastodonPluginInfo : public SharingPluginInfo -{ - Q_OBJECT - -public: - MastodonPluginInfo(); - ~MastodonPluginInfo(); - - QList info() const; - void query(); - -private Q_SLOTS: - void serviceReady(); - -private: - MastodonShareServiceStatus *m_mastodonShareServiceStatus; - QList m_info; - QStringList m_capabilities; -}; - -#endif // MASTODONPLUGININFO_H diff --git a/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.cpp b/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.cpp deleted file mode 100644 index 8c139a2..0000000 --- a/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.cpp +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -#include "mastodonshareplugin.h" -#include "mastodonplugininfo.h" - -#include - -MastodonSharePlugin::MastodonSharePlugin() - : QObject(), SharingPluginInterface() -{ -} - -MastodonSharePlugin::~MastodonSharePlugin() -{ -} - -SharingPluginInfo *MastodonSharePlugin::infoObject() -{ - return new MastodonPluginInfo; -} - -QString MastodonSharePlugin::pluginId() const -{ - return QLatin1String("Mastodon"); -} diff --git a/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.h b/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.h deleted file mode 100644 index 04d8412..0000000 --- a/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.h +++ /dev/null @@ -1,28 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -#ifndef MASTODONSHAREPLUGIN_H -#define MASTODONSHAREPLUGIN_H - -#include - -#include - -class Q_DECL_EXPORT MastodonSharePlugin : public QObject, public SharingPluginInterface -{ - Q_OBJECT - Q_PLUGIN_METADATA(IID "org.sailfishos.share.plugin.mastodon") - Q_INTERFACES(SharingPluginInterface) - -public: - MastodonSharePlugin(); - ~MastodonSharePlugin(); - - SharingPluginInfo *infoObject(); - QString pluginId() const; -}; - -#endif // MASTODONSHAREPLUGIN_H diff --git a/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.pro b/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.pro deleted file mode 100644 index 59fb7e1..0000000 --- a/transferengine-plugins/mastodonshareplugin/mastodonshareplugin.pro +++ /dev/null @@ -1,30 +0,0 @@ -# SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. -# -# SPDX-License-Identifier: BSD-3-Clause - -TEMPLATE = lib -TARGET = $$qtLibraryTarget(mastodonshareplugin) -CONFIG += plugin -DEPENDPATH += . -INCLUDEPATH += .. -INCLUDEPATH += ../../common - -CONFIG += link_pkgconfig -PKGCONFIG += nemotransferengine-qt5 accounts-qt5 sailfishaccounts libsignon-qt5 - -HEADERS += mastodonshareplugin.h \ - mastodonplugininfo.h \ - ../mastodonshareservicestatus.h - -SOURCES += mastodonshareplugin.cpp \ - mastodonplugininfo.cpp \ - ../mastodonshareservicestatus.cpp - -target.path = $$[QT_INSTALL_LIBS]/nemo-transferengine/plugins/sharing - -OTHER_FILES += *.qml - -shareui.files = MastodonSharePost.qml -shareui.path = /usr/share/nemo-transferengine/plugins/sharing - -INSTALLS += target shareui diff --git a/transferengine-plugins/mastodonshareservicestatus.cpp b/transferengine-plugins/mastodonshareservicestatus.cpp deleted file mode 100644 index 2591520..0000000 --- a/transferengine-plugins/mastodonshareservicestatus.cpp +++ /dev/null @@ -1,297 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -#include "mastodonshareservicestatus.h" -#include "mastodonauthutils.h" - -#include -#include -#include -#include - -#include -#include -#include -#include - -#include -#include -#include - -MastodonShareServiceStatus::MastodonShareServiceStatus(QObject *parent) - : QObject(parent) - , m_auth(new AccountAuthenticator(this)) - , m_accountManager(new Accounts::Manager(this)) - , m_serviceName(QStringLiteral("mastodon-sharing")) -{ -} - -QString MastodonShareServiceStatus::authServiceName() const -{ - return QStringLiteral("mastodon-microblog"); -} - -void MastodonShareServiceStatus::signIn(int accountId) -{ - Accounts::Account *account = Accounts::Account::fromId(m_accountManager, accountId, this); - if (!account) { - qWarning() << Q_FUNC_INFO << "Failed to retrieve account for id:" << accountId; - setAccountDetailsState(accountId, Error); - return; - } - - const Accounts::Service service(m_accountManager->service(authServiceName())); - if (!service.isValid()) { - qWarning() << Q_FUNC_INFO << "Invalid auth service" << authServiceName(); - account->deleteLater(); - setAccountDetailsState(accountId, Error); - return; - } - - account->selectService(service); - - SignOn::Identity *identity = account->credentialsId() > 0 - ? SignOn::Identity::existingIdentity(account->credentialsId()) - : 0; - if (!identity) { - qWarning() << Q_FUNC_INFO << "account" << accountId << "has no valid credentials"; - account->deleteLater(); - setAccountDetailsState(accountId, Error); - return; - } - - Accounts::AccountService accountService(account, service); - const QString method = accountService.authData().method(); - const QString mechanism = accountService.authData().mechanism(); - - SignOn::AuthSession *session = identity->createSession(method); - if (!session) { - qWarning() << Q_FUNC_INFO << "could not create signon session for account" << accountId; - identity->deleteLater(); - account->deleteLater(); - setAccountDetailsState(accountId, Error); - return; - } - - QVariantMap signonSessionData = accountService.authData().parameters(); - - MastodonAuthUtils::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(account)); - session->setProperty("identity", QVariant::fromValue(identity)); - session->process(SignOn::SessionData(signonSessionData), mechanism); -} - -void MastodonShareServiceStatus::signOnResponse(const SignOn::SessionData &responseData) -{ - const QVariantMap data = MastodonAuthUtils::responseDataToMap(responseData); - - SignOn::AuthSession *session = qobject_cast(sender()); - Accounts::Account *account = session->property("account").value(); - SignOn::Identity *identity = session->property("identity").value(); - const int accountId = account ? account->id() : 0; - - QString accessToken = MastodonAuthUtils::accessToken(data); - - if (accountId > 0 && m_accountIdToDetailsIdx.contains(accountId)) { - AccountDetails &accountDetails(m_accountDetails[m_accountIdToDetailsIdx[accountId]]); - accountDetails.accessToken = accessToken; - setAccountDetailsState(accountId, accessToken.isEmpty() ? Error : Populated); - } - - session->disconnect(this); - if (identity) { - identity->destroySession(session); - identity->deleteLater(); - } - if (account) { - account->deleteLater(); - } -} - -void MastodonShareServiceStatus::signOnError(const SignOn::Error &error) -{ - SignOn::AuthSession *session = qobject_cast(sender()); - Accounts::Account *account = session->property("account").value(); - SignOn::Identity *identity = session->property("identity").value(); - const int accountId = account ? account->id() : 0; - - qWarning() << Q_FUNC_INFO << "failed to retrieve credentials for account" << accountId - << error.type() << error.message(); - - if (accountId > 0 && error.type() == SignOn::Error::UserInteraction) { - setCredentialsNeedUpdate(accountId, authServiceName()); - } - - session->disconnect(this); - if (identity) { - identity->destroySession(session); - identity->deleteLater(); - } - if (account) { - account->deleteLater(); - } - - if (accountId > 0) { - setAccountDetailsState(accountId, Error); - } -} - -void MastodonShareServiceStatus::setAccountDetailsState(int accountId, AccountDetailsState state) -{ - if (!m_accountIdToDetailsIdx.contains(accountId)) { - return; - } - - m_accountDetailsState[accountId] = state; - - bool anyWaiting = false; - bool anyPopulated = false; - Q_FOREACH (int id, m_accountDetailsState.keys()) { - AccountDetailsState accountState = m_accountDetailsState.value(id, Waiting); - if (accountState == Waiting) { - anyWaiting = true; - } else if (accountState == Populated) { - anyPopulated = true; - } - } - - if (!anyWaiting) { - if (anyPopulated) { - emit serviceReady(); - } else { - emit serviceError(QStringLiteral("Unable to retrieve Mastodon account credentials")); - } - } -} - -int MastodonShareServiceStatus::count() const -{ - return m_accountDetails.count(); -} - -bool MastodonShareServiceStatus::setCredentialsNeedUpdate(int accountId, const QString &serviceName) -{ - return m_auth->setCredentialsNeedUpdate(accountId, serviceName); -} - -void MastodonShareServiceStatus::queryStatus(QueryStatusMode mode) -{ - m_accountDetails.clear(); - m_accountIdToDetailsIdx.clear(); - m_accountDetailsState.clear(); - - bool signInActive = false; - Q_FOREACH (Accounts::AccountId id, m_accountManager->accountList()) { - Accounts::Account *acc = Accounts::Account::fromId(m_accountManager, id, this); - - if (!acc) { - qWarning() << Q_FUNC_INFO << "Failed to get account for id:" << id; - continue; - } - - acc->selectService(Accounts::Service()); - - const Accounts::Service service(m_accountManager->service(m_serviceName)); - const Accounts::ServiceList services = acc->services(); - bool serviceFound = false; - Q_FOREACH (const Accounts::Service &s, services) { - if (s.name() == m_serviceName) { - serviceFound = true; - break; - } - } - - if (!service.isValid() || !serviceFound) { - acc->deleteLater(); - continue; - } - - const bool accountEnabled = acc->enabled(); - acc->selectService(service); - const bool shareServiceEnabled = acc->enabled(); - if (!accountEnabled || !shareServiceEnabled) { - acc->selectService(Accounts::Service()); - acc->deleteLater(); - continue; - } - - if (acc->value(QStringLiteral("CredentialsNeedUpdate")).toBool()) { - qWarning() << Q_FUNC_INFO << "Credentials need update for account id:" << id; - acc->selectService(Accounts::Service()); - acc->deleteLater(); - continue; - } - - if (!m_accountIdToDetailsIdx.contains(id)) { - AccountDetails details; - details.accountId = id; - acc->selectService(Accounts::Service()); - details.apiHost = MastodonAuthUtils::normalizeApiHost(acc->value(QStringLiteral("api/Host")).toString()); - acc->selectService(service); - - QUrl apiUrl(details.apiHost); - details.providerName = apiUrl.host(); - if (details.providerName.isEmpty()) { - details.providerName = details.apiHost; - if (details.providerName.startsWith(QLatin1String("https://"))) { - details.providerName.remove(0, 8); - } else if (details.providerName.startsWith(QLatin1String("http://"))) { - details.providerName.remove(0, 7); - } - const int separator = details.providerName.indexOf(QLatin1Char('/')); - if (separator > 0) { - details.providerName.truncate(separator); - } - } - - details.displayName = acc->displayName(); - - m_accountIdToDetailsIdx.insert(id, m_accountDetails.size()); - m_accountDetails.append(details); - } - - if (mode == SignInMode) { - signInActive = true; - m_accountDetailsState.insert(id, Waiting); - signIn(id); - } - - acc->selectService(Accounts::Service()); - acc->deleteLater(); - } - - if (!signInActive) { - emit serviceReady(); - } -} - -MastodonShareServiceStatus::AccountDetails MastodonShareServiceStatus::details(int index) const -{ - if (index < 0 || index >= m_accountDetails.size()) { - qWarning() << Q_FUNC_INFO << "Index out of range"; - return AccountDetails(); - } - - return m_accountDetails.at(index); -} - -MastodonShareServiceStatus::AccountDetails MastodonShareServiceStatus::detailsByIdentifier(int accountIdentifier) const -{ - if (!m_accountIdToDetailsIdx.contains(accountIdentifier)) { - qWarning() << Q_FUNC_INFO << "No details known for account with identifier" << accountIdentifier; - return AccountDetails(); - } - - return m_accountDetails[m_accountIdToDetailsIdx[accountIdentifier]]; -} diff --git a/transferengine-plugins/mastodonshareservicestatus.h b/transferengine-plugins/mastodonshareservicestatus.h deleted file mode 100644 index be76c37..0000000 --- a/transferengine-plugins/mastodonshareservicestatus.h +++ /dev/null @@ -1,81 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -#ifndef MASTODONSHARESERVICESTATUS_H -#define MASTODONSHARESERVICESTATUS_H - -#include -#include -#include - -#include - -namespace Accounts { -class Account; -class Manager; -} - -namespace SignOn { -class Error; -class SessionData; -} - -class MastodonShareServiceStatus : public QObject -{ - Q_OBJECT - -public: - explicit MastodonShareServiceStatus(QObject *parent = 0); - - enum QueryStatusMode { - PassiveMode = 0, - SignInMode = 1 - }; - - void queryStatus(QueryStatusMode mode = SignInMode); - - struct AccountDetails { - int accountId = 0; - QString providerName; - QString displayName; - QString accessToken; - QString apiHost; - }; - - AccountDetails details(int index = 0) const; - AccountDetails detailsByIdentifier(int accountIdentifier) const; - int count() const; - - bool setCredentialsNeedUpdate(int accountId, const QString &serviceName); - -Q_SIGNALS: - void serviceReady(); - void serviceError(const QString &message); - -private Q_SLOTS: - void signOnResponse(const SignOn::SessionData &responseData); - void signOnError(const SignOn::Error &error); - -private: - enum AccountDetailsState { - Waiting, - Populated, - Error - }; - - QString authServiceName() const; - void setAccountDetailsState(int accountId, AccountDetailsState state); - void signIn(int accountId); - - AccountAuthenticator *m_auth; - Accounts::Manager *m_accountManager; - QString m_serviceName; - QVector m_accountDetails; - QHash m_accountIdToDetailsIdx; - QHash m_accountDetailsState; -}; - -#endif // MASTODONSHARESERVICESTATUS_H diff --git a/transferengine-plugins/mastodontransferplugin/mastodonapi.cpp b/transferengine-plugins/mastodontransferplugin/mastodonapi.cpp deleted file mode 100644 index fa973d0..0000000 --- a/transferengine-plugins/mastodontransferplugin/mastodonapi.cpp +++ /dev/null @@ -1,255 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -#include "mastodonapi.h" -#include "mastodonauthutils.h" - -#include -#include -#include -#include -#include -#include -#include - -#include - -#include - -MastodonApi::MastodonApi(QNetworkAccessManager *qnam, QObject *parent) - : QObject(parent) - , m_cancelRequested(false) - , m_qnam(qnam) -{ -} - -MastodonApi::~MastodonApi() -{ -} - -bool MastodonApi::uploadImage(const QString &filePath, - const QString &statusText, - const QString &mimeType, - const QString &apiHost, - const QString &accessToken) -{ - QFile file(filePath); - if (filePath.isEmpty() || !file.open(QIODevice::ReadOnly)) { - qWarning() << Q_FUNC_INFO << "error opening file:" << filePath; - return false; - } - - m_cancelRequested = false; - m_apiHost = MastodonAuthUtils::normalizeApiHost(apiHost); - m_accessToken = accessToken; - m_statusText = statusText; - - if (m_accessToken.isEmpty()) { - qWarning() << Q_FUNC_INFO << "missing access token"; - return false; - } - - const QByteArray imageData = file.readAll(); - const QFileInfo fileInfo(filePath); - - QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); - - QHttpPart filePart; - filePart.setHeader(QNetworkRequest::ContentDispositionHeader, - QVariant(QStringLiteral("form-data; name=\"file\"; filename=\"%1\"") - .arg(fileInfo.fileName()))); - if (!mimeType.isEmpty()) { - filePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(mimeType)); - } - filePart.setBody(imageData); - multiPart->append(filePart); - - QNetworkRequest request(QUrl(m_apiHost + QStringLiteral("/api/v1/media"))); - request.setRawHeader(QByteArrayLiteral("Authorization"), - QByteArrayLiteral("Bearer ") + m_accessToken.toUtf8()); - - QNetworkReply *reply = m_qnam->post(request, multiPart); - if (!reply) { - delete multiPart; - return false; - } - - multiPart->setParent(reply); - m_replies.insert(reply, UPLOAD_MEDIA); - - connect(reply, SIGNAL(error(QNetworkReply::NetworkError)), - this, SLOT(replyError(QNetworkReply::NetworkError))); - connect(reply, &QNetworkReply::uploadProgress, - this, &MastodonApi::uploadProgress); - connect(reply, &QNetworkReply::finished, - this, &MastodonApi::finished); - - return true; -} - -bool MastodonApi::postStatus(const QString &statusText, - const QString &apiHost, - const QString &accessToken) -{ - m_cancelRequested = false; - m_apiHost = MastodonAuthUtils::normalizeApiHost(apiHost); - m_accessToken = accessToken; - m_statusText = statusText; - - if (m_accessToken.isEmpty()) { - qWarning() << Q_FUNC_INFO << "missing access token"; - return false; - } - - return postStatusInternal(QString()); -} - -bool MastodonApi::postStatusInternal(const QString &mediaId) -{ - if (m_statusText.trimmed().isEmpty() && mediaId.isEmpty()) { - qWarning() << Q_FUNC_INFO << "status and media id are empty"; - return false; - } - - QUrlQuery query; - if (!m_statusText.isEmpty()) { - query.addQueryItem(QStringLiteral("status"), m_statusText); - } - if (!mediaId.isEmpty()) { - query.addQueryItem(QStringLiteral("media_ids[]"), mediaId); - } - - const QByteArray postData = query.query(QUrl::FullyEncoded).toUtf8(); - - QNetworkRequest request(QUrl(m_apiHost + QStringLiteral("/api/v1/statuses"))); - request.setRawHeader(QByteArrayLiteral("Authorization"), - QByteArrayLiteral("Bearer ") + m_accessToken.toUtf8()); - request.setHeader(QNetworkRequest::ContentTypeHeader, - QVariant(QStringLiteral("application/x-www-form-urlencoded"))); - - QNetworkReply *reply = m_qnam->post(request, postData); - if (!reply) { - return false; - } - - m_replies.insert(reply, POST_STATUS); - connect(reply, SIGNAL(error(QNetworkReply::NetworkError)), - this, SLOT(replyError(QNetworkReply::NetworkError))); - connect(reply, &QNetworkReply::finished, - this, &MastodonApi::finished); - - return true; -} - -void MastodonApi::cancelUpload() -{ - if (m_replies.isEmpty()) { - qWarning() << Q_FUNC_INFO << "can't cancel upload"; - return; - } - - m_cancelRequested = true; - const QList replies = m_replies.keys(); - Q_FOREACH (QNetworkReply *reply, replies) { - reply->abort(); - } -} - -void MastodonApi::replyError(QNetworkReply::NetworkError error) -{ - Q_UNUSED(error) -} - -void MastodonApi::uploadProgress(qint64 sent, qint64 total) -{ - if (total > 0) { - emit transferProgressUpdated(sent / static_cast(total)); - } -} - -void MastodonApi::finished() -{ - QNetworkReply *reply = qobject_cast(sender()); - if (!reply || !m_replies.contains(reply)) { - return; - } - - const API_CALL apiCall = m_replies.take(reply); - const QByteArray data = reply->readAll(); - const int httpCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - const QNetworkReply::NetworkError error = reply->error(); - - reply->deleteLater(); - - if (m_cancelRequested && error == QNetworkReply::OperationCanceledError) { - if (m_replies.isEmpty()) { - m_cancelRequested = false; - emit transferCanceled(); - } - return; - } - - if (apiCall == UPLOAD_MEDIA) { - if (error != QNetworkReply::NoError || httpCode < 200 || httpCode >= 300) { - finishTransfer(error == QNetworkReply::NoError ? QNetworkReply::UnknownNetworkError : error, - httpCode, - data); - return; - } - - QString mediaId; - const QJsonDocument doc = QJsonDocument::fromJson(data); - if (doc.isObject()) { - const QJsonValue idValue = doc.object().value(QStringLiteral("id")); - if (idValue.isString()) { - mediaId = idValue.toString(); - } else if (idValue.isDouble()) { - mediaId = QString::number(static_cast(idValue.toDouble())); - } - } - - if (!postStatusInternal(mediaId)) { - qWarning() << Q_FUNC_INFO << "unable to create mastodon status"; - emit transferError(); - } - return; - } - - if (apiCall == POST_STATUS) { - finishTransfer(error, httpCode, data); - return; - } - - emit transferError(); -} - -void MastodonApi::finishTransfer(QNetworkReply::NetworkError error, int httpCode, const QByteArray &data) -{ - m_cancelRequested = false; - - if (httpCode == 401) { - emit credentialsExpired(); - } - - if (error != QNetworkReply::NoError) { - if (error == QNetworkReply::OperationCanceledError) { - emit transferCanceled(); - return; - } - - qWarning() << Q_FUNC_INFO << "network error:" << error << "httpCode:" << httpCode << "data:" << data; - emit transferError(); - return; - } - - if (httpCode < 200 || httpCode >= 300) { - qWarning() << Q_FUNC_INFO << "http error:" << httpCode << "data:" << data; - emit transferError(); - return; - } - - emit transferFinished(); -} diff --git a/transferengine-plugins/mastodontransferplugin/mastodonapi.h b/transferengine-plugins/mastodontransferplugin/mastodonapi.h deleted file mode 100644 index df4c87a..0000000 --- a/transferengine-plugins/mastodontransferplugin/mastodonapi.h +++ /dev/null @@ -1,65 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -#ifndef MASTODONAPI_H -#define MASTODONAPI_H - -#include -#include - -#include -#include - -class MastodonApi : public QObject -{ - Q_OBJECT - -public: - enum API_CALL { - NONE, - UPLOAD_MEDIA, - POST_STATUS - }; - - explicit MastodonApi(QNetworkAccessManager *qnam, QObject *parent = 0); - ~MastodonApi(); - - bool uploadImage(const QString &filePath, - const QString &statusText, - const QString &mimeType, - const QString &apiHost, - const QString &accessToken); - bool postStatus(const QString &statusText, - const QString &apiHost, - const QString &accessToken); - - void cancelUpload(); - -Q_SIGNALS: - void transferProgressUpdated(qreal progress); - void transferFinished(); - void transferError(); - void transferCanceled(); - void credentialsExpired(); - -private Q_SLOTS: - void replyError(QNetworkReply::NetworkError error); - void finished(); - void uploadProgress(qint64 received, qint64 total); - -private: - bool postStatusInternal(const QString &mediaId); - void finishTransfer(QNetworkReply::NetworkError error, int httpCode, const QByteArray &data); - - QMap m_replies; - bool m_cancelRequested; - QNetworkAccessManager *m_qnam; - QString m_accessToken; - QString m_apiHost; - QString m_statusText; -}; - -#endif // MASTODONAPI_H diff --git a/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.cpp b/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.cpp deleted file mode 100644 index a843df2..0000000 --- a/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.cpp +++ /dev/null @@ -1,31 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -#include "mastodontransferplugin.h" -#include "mastodonuploader.h" - -#include -#include - -MastodonTransferPlugin::MastodonTransferPlugin() - : QObject(), TransferPluginInterface() - , m_qnam(new QNetworkAccessManager(this)) -{ -} - -MastodonTransferPlugin::~MastodonTransferPlugin() -{ -} - -MediaTransferInterface *MastodonTransferPlugin::transferObject() -{ - return new MastodonUploader(m_qnam, this); -} - -QString MastodonTransferPlugin::pluginId() const -{ - return QLatin1String("Mastodon"); -} diff --git a/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.h b/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.h deleted file mode 100644 index 4d3baaf..0000000 --- a/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.h +++ /dev/null @@ -1,33 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -#ifndef MASTODONTRANSFERPLUGIN_H -#define MASTODONTRANSFERPLUGIN_H - -#include - -#include - -class QNetworkAccessManager; - -class Q_DECL_EXPORT MastodonTransferPlugin : public QObject, public TransferPluginInterface -{ - Q_OBJECT - Q_PLUGIN_METADATA(IID "org.sailfishos.transfer.plugin.mastodon") - Q_INTERFACES(TransferPluginInterface) - -public: - MastodonTransferPlugin(); - ~MastodonTransferPlugin(); - - MediaTransferInterface *transferObject(); - QString pluginId() const; - -private: - QNetworkAccessManager *m_qnam; -}; - -#endif // MASTODONTRANSFERPLUGIN_H diff --git a/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.pro b/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.pro deleted file mode 100644 index 422a889..0000000 --- a/transferengine-plugins/mastodontransferplugin/mastodontransferplugin.pro +++ /dev/null @@ -1,29 +0,0 @@ -# SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. -# -# SPDX-License-Identifier: BSD-3-Clause - -TEMPLATE = lib -TARGET = $$qtLibraryTarget(mastodontransferplugin) -CONFIG += plugin -DEPENDPATH += . -INCLUDEPATH += .. -INCLUDEPATH += ../../common - -QT += network - -CONFIG += link_pkgconfig -PKGCONFIG += nemotransferengine-qt5 accounts-qt5 sailfishaccounts libsignon-qt5 - -HEADERS += mastodontransferplugin.h \ - mastodonuploader.h \ - ../mastodonshareservicestatus.h \ - mastodonapi.h - -SOURCES += mastodontransferplugin.cpp \ - mastodonuploader.cpp \ - ../mastodonshareservicestatus.cpp \ - mastodonapi.cpp - -target.path = $$[QT_INSTALL_LIBS]/nemo-transferengine/plugins/transfer - -INSTALLS += target diff --git a/transferengine-plugins/mastodontransferplugin/mastodonuploader.cpp b/transferengine-plugins/mastodontransferplugin/mastodonuploader.cpp deleted file mode 100644 index 7b87823..0000000 --- a/transferengine-plugins/mastodontransferplugin/mastodonuploader.cpp +++ /dev/null @@ -1,252 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -#include "mastodonuploader.h" -#include "mastodonapi.h" - -#include -#include - -#include -#include -#include - -#include - -MastodonUploader::MastodonUploader(QNetworkAccessManager *qnam, QObject *parent) - : MediaTransferInterface(parent) - , m_api(0) - , m_mastodonShareServiceStatus(0) - , m_qnam(qnam) - , m_useTmpFile(false) -{ -} - -MastodonUploader::~MastodonUploader() -{ -} - -QString MastodonUploader::displayName() const -{ - return tr("Mastodon"); -} - -QUrl MastodonUploader::serviceIcon() const -{ - return QUrl(QStringLiteral("image://theme/icon-l-mastodon")); -} - -bool MastodonUploader::cancelEnabled() const -{ - return true; -} - -bool MastodonUploader::restartEnabled() const -{ - return true; -} - -void MastodonUploader::start() -{ - if (!mediaItem()) { - qWarning() << Q_FUNC_INFO << "NULL MediaItem. Can't continue"; - setStatus(MediaTransferInterface::TransferInterrupted); - return; - } - - if (!m_mastodonShareServiceStatus) { - m_mastodonShareServiceStatus = new MastodonShareServiceStatus(this); - connect(m_mastodonShareServiceStatus, &MastodonShareServiceStatus::serviceReady, - this, &MastodonUploader::startUploading); - connect(m_mastodonShareServiceStatus, &MastodonShareServiceStatus::serviceError, - this, [this] (const QString &) { - transferError(); - }); - } - - m_mastodonShareServiceStatus->queryStatus(); -} - -void MastodonUploader::cancel() -{ - if (m_api) { - m_api->cancelUpload(); - } else { - qWarning() << Q_FUNC_INFO << "Can't cancel. NULL MastodonApi object!"; - } -} - -void MastodonUploader::startUploading() -{ - if (!m_mastodonShareServiceStatus) { - qWarning() << Q_FUNC_INFO << "NULL MastodonShareServiceStatus object!"; - return; - } - - const quint32 accountId = mediaItem()->value(MediaItem::AccountId).toInt(); - m_accountDetails = m_mastodonShareServiceStatus->detailsByIdentifier(accountId); - if (m_accountDetails.accountId <= 0 || m_accountDetails.accessToken.isEmpty()) { - qWarning() << Q_FUNC_INFO << "Mastodon account details missing for id" << accountId; - transferError(); - return; - } - - const QString mimeType = mediaItem()->value(MediaItem::MimeType).toString(); - if (mimeType.startsWith(QLatin1String("image/")) - || mimeType.startsWith(QLatin1String("video/"))) { - postImage(); - } else if (mimeType.contains(QLatin1String("text/plain")) - || mimeType.contains(QLatin1String("text/x-url"))) { - postStatus(); - } else { - qWarning() << Q_FUNC_INFO << "Unsupported mime type:" << mimeType; - setStatus(MediaTransferInterface::TransferInterrupted); - } -} - -void MastodonUploader::transferFinished() -{ - setStatus(MediaTransferInterface::TransferFinished); -} - -void MastodonUploader::transferProgress(qreal progress) -{ - setProgress(progress); -} - -void MastodonUploader::transferError() -{ - setStatus(MediaTransferInterface::TransferInterrupted); - qWarning() << Q_FUNC_INFO << "Transfer interrupted"; -} - -void MastodonUploader::transferCanceled() -{ - setStatus(MediaTransferInterface::TransferCanceled); -} - -void MastodonUploader::credentialsExpired() -{ - const quint32 accountId = mediaItem()->value(MediaItem::AccountId).toInt(); - m_mastodonShareServiceStatus->setCredentialsNeedUpdate(accountId, QStringLiteral("mastodon-sharing")); -} - -void MastodonUploader::setStatus(MediaTransferInterface::TransferStatus status) -{ - const bool finished = (status == TransferCanceled - || status == TransferInterrupted - || status == TransferFinished); - if (m_useTmpFile && finished) { - QFile::remove(m_filePath); - m_useTmpFile = false; - m_filePath.clear(); - } - - MediaTransferInterface::setStatus(status); -} - -void MastodonUploader::postImage() -{ - m_useTmpFile = false; - m_filePath.clear(); - const QString sourceFile = mediaItem()->value(MediaItem::Url).toUrl().toLocalFile(); - if (sourceFile.isEmpty()) { - qWarning() << Q_FUNC_INFO << "Empty source file"; - setStatus(MediaTransferInterface::TransferInterrupted); - return; - } - - QMimeDatabase db; - const QMimeType mime = db.mimeTypeForFile(sourceFile); - const bool isImage = mediaItem()->value(MediaItem::MimeType).toString().startsWith(QLatin1String("image/")); - const bool isJpeg = isImage && mime.name() == QLatin1String("image/jpeg"); - - if (isJpeg && mediaItem()->value(MediaItem::MetadataStripped).toBool()) { - m_useTmpFile = true; - m_filePath = ImageOperation::removeImageMetadata(sourceFile); - if (m_filePath.isEmpty()) { - qWarning() << Q_FUNC_INFO << "Failed to remove metadata"; - MediaTransferInterface::setStatus(MediaTransferInterface::TransferInterrupted); - return; - } - } - - const qreal scale = mediaItem()->value(MediaItem::ScalePercent).toReal(); - if (isImage && 0 < scale && scale < 1) { - m_useTmpFile = true; - m_filePath = ImageOperation::scaleImage(sourceFile, scale, m_filePath); - if (m_filePath.isEmpty()) { - qWarning() << Q_FUNC_INFO << "Failed to scale image"; - MediaTransferInterface::setStatus(MediaTransferInterface::TransferInterrupted); - return; - } - } - - if (!m_useTmpFile) { - m_filePath = sourceFile; - } - - ensureApi(); - - const bool ok = m_api->uploadImage(m_filePath, - mediaItem()->value(MediaItem::Description).toString(), - mediaItem()->value(MediaItem::MimeType).toString(), - m_accountDetails.apiHost, - m_accountDetails.accessToken); - if (ok) { - setStatus(MediaTransferInterface::TransferStarted); - } else { - setStatus(MediaTransferInterface::TransferInterrupted); - qWarning() << Q_FUNC_INFO << "Failed to upload media"; - } -} - -void MastodonUploader::postStatus() -{ - ensureApi(); - - const QVariantMap userData = mediaItem()->value(MediaItem::UserData).toMap(); - QString statusText = userData.value(QStringLiteral("status")).toString().trimmed(); - if (statusText.isEmpty()) { - statusText = mediaItem()->value(MediaItem::Description).toString().trimmed(); - } - if (statusText.isEmpty()) { - statusText = mediaItem()->value(MediaItem::ContentData).toString().trimmed(); - } - - if (statusText.isEmpty()) { - qWarning() << Q_FUNC_INFO << "Failed to resolve status text"; - setStatus(MediaTransferInterface::TransferInterrupted); - return; - } - - const bool ok = m_api->postStatus(statusText, - m_accountDetails.apiHost, - m_accountDetails.accessToken); - if (ok) { - setStatus(MediaTransferInterface::TransferStarted); - } else { - setStatus(MediaTransferInterface::TransferInterrupted); - qWarning() << Q_FUNC_INFO << "Failed to post status"; - } -} - -void MastodonUploader::ensureApi() -{ - if (!m_api) { - m_api = new MastodonApi(m_qnam, this); - connect(m_api, &MastodonApi::transferProgressUpdated, - this, &MastodonUploader::transferProgress); - connect(m_api, &MastodonApi::transferFinished, - this, &MastodonUploader::transferFinished); - connect(m_api, &MastodonApi::transferError, - this, &MastodonUploader::transferError); - connect(m_api, &MastodonApi::transferCanceled, - this, &MastodonUploader::transferCanceled); - connect(m_api, &MastodonApi::credentialsExpired, - this, &MastodonUploader::credentialsExpired); - } -} diff --git a/transferengine-plugins/mastodontransferplugin/mastodonuploader.h b/transferengine-plugins/mastodontransferplugin/mastodonuploader.h deleted file mode 100644 index 72d9689..0000000 --- a/transferengine-plugins/mastodontransferplugin/mastodonuploader.h +++ /dev/null @@ -1,59 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2013 - 2026 Jolla Ltd. - * - * SPDX-License-Identifier: BSD-3-Clause - */ - -#ifndef MASTODONUPLOADER_H -#define MASTODONUPLOADER_H - -#include - -#include - -#include "mastodonshareservicestatus.h" - -class MastodonApi; - -class MastodonUploader : public MediaTransferInterface -{ - Q_OBJECT - -public: - MastodonUploader(QNetworkAccessManager *qnam, QObject *parent = 0); - ~MastodonUploader(); - - QString displayName() const; - QUrl serviceIcon() const; - bool cancelEnabled() const; - bool restartEnabled() const; - -public Q_SLOTS: - void start(); - void cancel(); - -private Q_SLOTS: - void startUploading(); - void transferFinished(); - void transferProgress(qreal progress); - void transferError(); - void transferCanceled(); - void credentialsExpired(); - -protected: - void setStatus(MediaTransferInterface::TransferStatus status); - -private: - void ensureApi(); - void postImage(); - void postStatus(); - - MastodonApi *m_api; - MastodonShareServiceStatus *m_mastodonShareServiceStatus; - QNetworkAccessManager *m_qnam; - MastodonShareServiceStatus::AccountDetails m_accountDetails; - bool m_useTmpFile; - QString m_filePath; -}; - -#endif // MASTODONUPLOADER_H diff --git a/transferengine-plugins/transferengine-plugins.pro b/transferengine-plugins/transferengine-plugins.pro index e2503a3..ce1a102 100644 --- a/transferengine-plugins/transferengine-plugins.pro +++ b/transferengine-plugins/transferengine-plugins.pro @@ -3,4 +3,4 @@ # SPDX-License-Identifier: BSD-3-Clause TEMPLATE = subdirs -SUBDIRS = mastodonshareplugin mastodontransferplugin +SUBDIRS = fediverseshareplugin fediversetransferplugin -- cgit v1.2.3