summaryrefslogtreecommitdiff
path: root/eventsview-plugins/eventsview-plugin-fediverse
diff options
context:
space:
mode:
Diffstat (limited to 'eventsview-plugins/eventsview-plugin-fediverse')
-rw-r--r--eventsview-plugins/eventsview-plugin-fediverse/FediverseFeedItem.qml362
-rw-r--r--eventsview-plugins/eventsview-plugin-fediverse/eventsview-plugin-fediverse.pro65
-rw-r--r--eventsview-plugins/eventsview-plugin-fediverse/fediverse-delegate.qml185
-rw-r--r--eventsview-plugins/eventsview-plugin-fediverse/fediversepostactions.cpp317
-rw-r--r--eventsview-plugins/eventsview-plugin-fediverse/fediversepostactions.h82
-rw-r--r--eventsview-plugins/eventsview-plugin-fediverse/fediversepostsmodel.cpp200
-rw-r--r--eventsview-plugins/eventsview-plugin-fediverse/fediversepostsmodel.h80
-rw-r--r--eventsview-plugins/eventsview-plugin-fediverse/plugin.cpp27
-rw-r--r--eventsview-plugins/eventsview-plugin-fediverse/qmldir4
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