summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md1
-rw-r--r--buteo-plugins/buteo-sync-plugin-mastodon-posts/mastodonpostssyncadaptor.cpp8
-rw-r--r--common/mastodonpostsdatabase.cpp22
-rw-r--r--common/mastodonpostsdatabase.h3
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/MastodonFeedItem.qml285
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/eventsview-plugin-mastodon.pro6
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/mastodon-delegate.qml22
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/mastodonpostactions.cpp317
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/mastodonpostactions.h82
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.cpp6
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/mastodonpostsmodel.h2
-rw-r--r--eventsview-plugins/eventsview-plugin-mastodon/plugin.cpp2
-rw-r--r--settings/accounts/ui/mastodon.qml2
13 files changed, 736 insertions, 22 deletions
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<QPair<QString, SocialPostImage::ImageType> > 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<QPair<QString, SocialPostImage::ImageType> > &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 <Accounts/Account>
+#include <Accounts/AccountService>
+#include <Accounts/Manager>
+#include <Accounts/Service>
+
+#include <SignOn/AuthSession>
+#include <SignOn/Error>
+#include <SignOn/Identity>
+#include <SignOn/SessionData>
+
+#include <QtCore/QJsonDocument>
+#include <QtCore/QJsonObject>
+#include <QtCore/QVariantMap>
+#include <QtCore/QUrl>
+#include <QtNetwork/QNetworkReply>
+#include <QtNetwork/QNetworkRequest>
+#include <QtDebug>
+
+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<Accounts::Account *>(account));
+ session->setProperty("identity", QVariant::fromValue<SignOn::Identity *>(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<SignOn::AuthSession *>(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<Accounts::Account *>();
+ 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<SignOn::AuthSession *>(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<QNetworkReply *>(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<SignOn::AuthSession *>(sessionObject);
+ if (!session) {
+ return;
+ }
+
+ Accounts::Account *account = session->property("account").value<Accounts::Account *>();
+ SignOn::Identity *identity = session->property("identity").value<SignOn::Identity *>();
+
+ 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 <QtCore/QObject>
+#include <QtCore/QSet>
+#include <QtCore/QString>
+#include <QtNetwork/QNetworkAccessManager>
+
+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<QString> 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<int, QByteArray> 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 <QQmlExtensionPlugin>
#include <QtQml>
+#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<MastodonPostsModel>(uri, 1, 0, "MastodonPostsModel");
+ qmlRegisterType<MastodonPostActions>(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"))