From bce74e963abeb96a9c335f5461611dee544abc4c Mon Sep 17 00:00:00 2001 From: Andrew Branson Date: Thu, 12 Feb 2026 12:34:14 +0100 Subject: Enhance Mastodon post actions and metadata handling for reblogs and favourites --- README.md | 1 + .../mastodonpostssyncadaptor.cpp | 8 + common/mastodonpostsdatabase.cpp | 22 ++ common/mastodonpostsdatabase.h | 3 + .../MastodonFeedItem.qml | 285 ++++++++++++++++-- .../eventsview-plugin-mastodon.pro | 6 +- .../mastodon-delegate.qml | 22 +- .../mastodonpostactions.cpp | 317 +++++++++++++++++++++ .../mastodonpostactions.h | 82 ++++++ .../mastodonpostsmodel.cpp | 6 + .../mastodonpostsmodel.h | 2 + .../eventsview-plugin-mastodon/plugin.cpp | 2 + settings/accounts/ui/mastodon.qml | 2 +- 13 files changed, 736 insertions(+), 22 deletions(-) create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/mastodonpostactions.cpp create mode 100644 eventsview-plugins/eventsview-plugin-mastodon/mastodonpostactions.h diff --git a/README.md b/README.md index 18be98c..2773c87 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Sailfish OS account integration for Mastodon. - 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. diff --git a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp index 6165931..deddb0a 100644 --- a/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp +++ b/buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp @@ -98,6 +98,8 @@ void MastodonPostsSyncAdaptor::purgeDataForOldAccount(int oldId, SocialNetworkSy m_db.removePosts(oldId); m_db.commit(); m_db.wait(); + m_db.refresh(); + m_db.wait(); purgeCachedImages(&m_imageCacheDb, oldId); } @@ -114,6 +116,8 @@ void MastodonPostsSyncAdaptor::finalize(int accountId) } else { m_db.commit(); m_db.wait(); + m_db.refresh(); + m_db.wait(); purgeExpiredImages(&m_imageCacheDb, accountId); } } @@ -273,6 +277,8 @@ void MastodonPostsSyncAdaptor::finishedPostsHandler() 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(); @@ -310,6 +316,8 @@ void MastodonPostsSyncAdaptor::finishedPostsHandler() repliesCount, favouritesCount, reblogsCount, + favourited, + reblogged, apiHost(accountId), accountId); } diff --git a/common/mastodonpostsdatabase.cpp b/common/mastodonpostsdatabase.cpp index ee04327..7f82162 100644 --- a/common/mastodonpostsdatabase.cpp +++ b/common/mastodonpostsdatabase.cpp @@ -25,6 +25,8 @@ 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() @@ -49,6 +51,8 @@ void MastodonPostsDatabase::addMastodonPost( int repliesCount, int favouritesCount, int reblogsCount, + bool favourited, + bool reblogged, const QString &instanceUrl, int account) { @@ -59,6 +63,8 @@ void MastodonPostsDatabase::addMastodonPost( 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); } @@ -111,6 +117,22 @@ int MastodonPostsDatabase::reblogsCount(const SocialPost::ConstPtr &post) 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()) { diff --git a/common/mastodonpostsdatabase.h b/common/mastodonpostsdatabase.h index 66d3f09..9736fa8 100644 --- a/common/mastodonpostsdatabase.h +++ b/common/mastodonpostsdatabase.h @@ -35,6 +35,7 @@ public: const QList > &images, const QString &url, const QString &boostedBy, int repliesCount, int favouritesCount, int reblogsCount, + bool favourited, bool reblogged, const QString &instanceUrl, int account); @@ -44,6 +45,8 @@ public: 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); }; diff --git a/eventsview-plugins/eventsview-plugin-mastodon/MastodonFeedItem.qml b/eventsview-plugins/eventsview-plugin-mastodon/MastodonFeedItem.qml index 9d86732..b9e5049 100644 --- a/eventsview-plugins/eventsview-plugin-mastodon/MastodonFeedItem.qml +++ b/eventsview-plugins/eventsview-plugin-mastodon/MastodonFeedItem.qml @@ -7,15 +7,32 @@ import QtQuick 2.0 import Sailfish.Silica 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 _contextMenuOpen: false + property var _actionMenu + property real _contextMenuHeight: (_contextMenuOpen && _actionMenu) ? _actionMenu.height : 0 property string _booster: item.stringValue("boostedBy", "rebloggedBy", "retweeter") property string _displayName: item.stringValue("name", "displayName", "display_name") @@ -24,11 +41,28 @@ SocialMediaFeedItem { timestamp: model.timestamp onRefreshTimeCountChanged: formattedTime = Format.formatDate(model.timestamp, Format.TimeElapsed) + onLockScreenActiveChanged: { + if (lockScreenActive && _actionMenu) { + _actionMenu.close() + } + } + onPressAndHold: { + if (!housekeeping && !lockScreenActive) { + _contextMenuOpen = false + openActionMenu() + } + } + 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 + contentHeight: Math.max(content.y + content.height, avatar.y + avatar.height) + bottomMargin + _contextMenuHeight topMargin: item._booster.length > 0 ? Theme.paddingMedium : Theme.paddingLarge userRemovable: false @@ -103,16 +137,61 @@ SocialMediaFeedItem { plainText: item._bodyText } - Text { + Row { + id: metadataRow + width: parent.width height: previewRow.visible ? implicitHeight + Theme.paddingMedium : implicitHeight // add padding below - maximumLineCount: 1 - elide: Text.ElideRight - wrapMode: Text.Wrap - color: item.highlighted ? Theme.secondaryHighlightColor : Theme.secondaryColor - font.pixelSize: Theme.fontSizeExtraSmall - text: item.metadataText() - textFormat: Text.PlainText + 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 { @@ -156,14 +235,186 @@ SocialMediaFeedItem { return 0 } - function metadataText() { - var parts = [] - parts.push("↩ " + item.commentCount) - parts.push("★ " + item.likeCount) - parts.push("↻ " + item.boostCount) - if (item.formattedTime.length > 0) { - parts.push(item.formattedTime) + 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 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 = xPos < width / 2 ? 0 : 1 + onDownChanged: if (!down) hoveredIndex = -1 + + onClicked: { + xPos = _contentColumn.mapFromItem(actionMenu, actionMenu.mouseX, actionMenu.mouseY).x + var index = hoveredIndex >= 0 ? hoveredIndex : (xPos < width / 2 ? 0 : 1) + 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 (item.isReblogged) { + item.postActions.unboost(accountId, postId) + } else { + item.postActions.boost(accountId, postId) + } + } + } + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + x: horizontalActions.hoveredIndex === 1 ? parent.width / 2 : 0 + width: parent.width / 2 + height: parent.height + visible: horizontalActions.down && horizontalActions.hoveredIndex >= 0 + color: Theme.rgba(Theme.highlightBackgroundColor, Theme.highlightBackgroundOpacity) + } + + Row { + anchors.fill: parent + + Label { + width: parent.width / 2 + 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 / 2 + 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) + } + } + } } - return parts.join(" | ") } } diff --git a/eventsview-plugins/eventsview-plugin-mastodon/eventsview-plugin-mastodon.pro b/eventsview-plugins/eventsview-plugin-mastodon/eventsview-plugin-mastodon.pro index 229f38a..109478e 100644 --- a/eventsview-plugins/eventsview-plugin-mastodon/eventsview-plugin-mastodon.pro +++ b/eventsview-plugins/eventsview-plugin-mastodon/eventsview-plugin-mastodon.pro @@ -5,9 +5,9 @@ TARGET = $$qtLibraryTarget($$TARGET) MODULENAME = com/jolla/eventsview/mastodon TARGETPATH = $$[QT_INSTALL_QML]/$$MODULENAME -QT += qml +QT += qml network CONFIG += plugin link_pkgconfig -PKGCONFIG += socialcache +PKGCONFIG += socialcache accounts-qt5 libsignon-qt5 sailfishaccounts include($$PWD/../../common/common.pri) @@ -42,10 +42,12 @@ INSTALLS += ts_install engineering_english_install HEADERS += \ abstractsocialcachemodel.h \ abstractsocialcachemodel_p.h \ + mastodonpostactions.h \ mastodonpostsmodel.h SOURCES += \ abstractsocialcachemodel.cpp \ + mastodonpostactions.cpp \ mastodonpostsmodel.cpp \ plugin.cpp diff --git a/eventsview-plugins/eventsview-plugin-mastodon/mastodon-delegate.qml b/eventsview-plugins/eventsview-plugin-mastodon/mastodon-delegate.qml index 906dc9c..aefaa96 100644 --- a/eventsview-plugins/eventsview-plugin-mastodon/mastodon-delegate.qml +++ b/eventsview-plugins/eventsview-plugin-mastodon/mastodon-delegate.qml @@ -25,6 +25,10 @@ SocialMediaAccountDelegate { dataType: SocialSync.Posts providerName: "mastodon" + MastodonPostActions { + id: mastodonPostActions + } + model: MastodonPostsModel { onCountChanged: { if (count > 0) { @@ -42,9 +46,12 @@ SocialMediaAccountDelegate { imageList: delegateItem.variantRole(model, ["images", "mediaAttachments", "media"]) avatarSource: delegateItem.convertUrl(delegateItem.stringRole(model, ["icon", "avatar", "avatarUrl"])) fallbackAvatarSource: delegateItem.stringRole(model, ["icon", "avatar", "avatarUrl"]) - accountId: model.accounts[0] + resolvedStatusUrl: delegateItem.statusUrl(model) + postId: delegateItem.stringRole(model, ["mastodonId", "statusId", "id", "twitterId"]) + postActions: mastodonPostActions + accountId: delegateItem.firstAccountId(model) - onTriggered: Qt.openUrlExternally(delegateItem.statusUrl(model)) + onTriggered: Qt.openUrlExternally(resolvedStatusUrl) Component.onCompleted: { refreshTimeCount = Qt.binding(function() { return delegateItem.refreshTimeCount }) @@ -164,4 +171,15 @@ SocialMediaAccountDelegate { } 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 new file mode 100644 index 0000000..371b7dd --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostactions.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 "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 new file mode 100644 index 0000000..cfe0c2a --- /dev/null +++ b/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostactions.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 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 index 4fe37d9..855d9be 100644 --- a/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.cpp +++ b/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.cpp @@ -66,6 +66,8 @@ QHash MastodonPostsModel::roleNames() const 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; @@ -106,6 +108,8 @@ void MastodonPostsModel::postsChanged() const int repliesCount = d->database.repliesCount(post); const int favouritesCount = d->database.favouritesCount(post); const int reblogsCount = d->database.reblogsCount(post); + const bool favourited = d->database.favourited(post); + const bool reblogged = d->database.reblogged(post); eventMap.insert(MastodonPostsModel::MastodonId, post->identifier()); eventMap.insert(MastodonPostsModel::Name, post->name()); @@ -121,6 +125,8 @@ void MastodonPostsModel::postsChanged() 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, d->database.instanceUrl(post)); QVariantList images; diff --git a/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.h b/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.h index 586d1a0..565b2bb 100644 --- a/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.h +++ b/eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.h @@ -45,6 +45,8 @@ public: RepliesCount, FavouritesCount, ReblogsCount, + Favourited, + Reblogged, InstanceUrl, Accounts }; diff --git a/eventsview-plugins/eventsview-plugin-mastodon/plugin.cpp b/eventsview-plugins/eventsview-plugin-mastodon/plugin.cpp index 35d95ca..2f90732 100644 --- a/eventsview-plugins/eventsview-plugin-mastodon/plugin.cpp +++ b/eventsview-plugins/eventsview-plugin-mastodon/plugin.cpp @@ -1,6 +1,7 @@ #include #include +#include "mastodonpostactions.h" #include "mastodonpostsmodel.h" class JollaEventsviewMastodonPlugin : public QQmlExtensionPlugin @@ -13,6 +14,7 @@ public: { Q_ASSERT(QLatin1String(uri) == QLatin1String("com.jolla.eventsview.mastodon")); qmlRegisterType(uri, 1, 0, "MastodonPostsModel"); + qmlRegisterType(uri, 1, 0, "MastodonPostActions"); } }; diff --git a/settings/accounts/ui/mastodon.qml b/settings/accounts/ui/mastodon.qml index 1ff94b5..9cc37fa 100644 --- a/settings/accounts/ui/mastodon.qml +++ b/settings/accounts/ui/mastodon.qml @@ -115,7 +115,7 @@ AccountCreationAgent { } var postData = [] - //% "Sailfish Mastodon" + //% "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")) -- cgit v1.2.3