diff options
Diffstat (limited to 'eventsview-plugins/eventsview-plugin-fediverse')
9 files changed, 1322 insertions, 0 deletions
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 <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 = "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<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 FediversePostActions::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 = FediverseAuthUtils::responseDataToMap(responseData); + const QString accessToken = FediverseAuthUtils::accessToken(data); + + Accounts::Account *account = session->property("account").value<Accounts::Account *>(); + 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<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 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<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 FediversePostActions::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 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 <QtCore/QObject> +#include <QtCore/QSet> +#include <QtCore/QString> +#include <QtNetwork/QNetworkAccessManager> + +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<QString> 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 <QtCore/QVariantList> + +namespace { + +QVariantList imageListForPost(const SocialPost::ConstPtr &post) +{ + QVariantList images; + if (!post) { + return images; + } + + const QList<SocialPostImage::ConstPtr> 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<int> postAccounts = post->accounts(); + for (int accountId : postAccounts) { + accounts.append(accountId); + } + return accounts; +} + +void appendCommonPostFields(QMap<int, QVariant> *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<int, QByteArray> FediversePostsModel::roleNames() const +{ + QHash<int, QByteArray> 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<RowData> data; + QList<SocialPost::ConstPtr> 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 <QtCore/QAbstractListModel> +#include <QtCore/QMap> + +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<int, QByteArray> 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<int, QVariant> RowData; + QList<RowData> 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 <QQmlExtensionPlugin> +#include <QtQml> + +#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<FediversePostsModel>(uri, 1, 0, "FediversePostsModel"); + qmlRegisterType<FediversePostActions>(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 |
