From 59a3a9514bbe8d79a9806bd2ea1b8f807ab050f4 Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Wed, 28 Aug 2024 09:39:01 +0100 Subject: [PATCH] Added data retrieval for activitypub activities (#20830) refs [AP-283](https://linear.app/tryghost/issue/AP-283/handle-incoming-likes) Added data retrieval for activitypub activities in the acvitiies tab of the ActivityPub demo --- apps/admin-x-activitypub/src/MainContent.tsx | 17 +++ .../src/api/activitypub.test.ts | 43 +----- .../src/api/activitypub.ts | 4 +- .../src/components/Activities.tsx | 129 +++++++++++++----- .../src/components/Inbox.tsx | 2 +- .../components/activities/ActivityItem.tsx | 17 ++- 6 files changed, 132 insertions(+), 80 deletions(-) diff --git a/apps/admin-x-activitypub/src/MainContent.tsx b/apps/admin-x-activitypub/src/MainContent.tsx index e8f05cba87..807ec785fb 100644 --- a/apps/admin-x-activitypub/src/MainContent.tsx +++ b/apps/admin-x-activitypub/src/MainContent.tsx @@ -24,6 +24,23 @@ export function useBrowseInboxForUser(handle: string) { }); } +export function useFollowersForUser(handle: string) { + const site = useBrowseSite(); + const siteData = site.data?.site; + const siteUrl = siteData?.url ?? window.location.origin; + const api = new ActivityPubAPI( + new URL(siteUrl), + new URL('/ghost/api/admin/identities/', window.location.origin), + handle + ); + return useQuery({ + queryKey: [`followers:${handle}`], + async queryFn() { + return api.getFollowers(); + } + }); +} + const MainContent = () => { const {route} = useRouting(); const mainRoute = route.split('/')[0]; diff --git a/apps/admin-x-activitypub/src/api/activitypub.test.ts b/apps/admin-x-activitypub/src/api/activitypub.test.ts index e194474e2c..7d50597447 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.test.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.test.ts @@ -334,7 +334,7 @@ describe('ActivityPubAPI', function () { }, response: JSONResponse({ type: 'Collection', - items: [] + orderedItems: [] }) } }); @@ -360,7 +360,7 @@ describe('ActivityPubAPI', function () { 'https://activitypub.api/.ghost/activitypub/followers/index': { response: JSONResponse({ type: 'Collection', - items: [] + orderedItems: [] }) } }); @@ -390,7 +390,7 @@ describe('ActivityPubAPI', function () { response: JSONResponse({ type: 'Collection', - items: [{ + orderedItems: [{ type: 'Person' }] }) @@ -413,43 +413,6 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); - - test('Returns an array when the items key is a single object', async function () { - const fakeFetch = Fetch({ - 'https://auth.api/': { - response: JSONResponse({ - identities: [{ - token: 'fake-token' - }] - }) - }, - 'https://activitypub.api/.ghost/activitypub/followers/index': { - response: - JSONResponse({ - type: 'Collection', - items: { - type: 'Person' - } - }) - } - }); - - const api = new ActivityPubAPI( - new URL('https://activitypub.api'), - new URL('https://auth.api'), - 'index', - fakeFetch - ); - - const actual = await api.getFollowers(); - const expected: Activity[] = [ - { - type: 'Person' - } - ]; - - expect(actual).toEqual(expected); - }); }); describe('follow', function () { diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts index edb0da0ac1..0549e5994f 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.ts @@ -85,8 +85,8 @@ export class ActivityPubAPI { if (json === null) { return []; } - if ('items' in json) { - return Array.isArray(json.items) ? json.items : [json.items]; + if ('orderedItems' in json) { + return json.orderedItems as Activity[]; } return []; } diff --git a/apps/admin-x-activitypub/src/components/Activities.tsx b/apps/admin-x-activitypub/src/components/Activities.tsx index bbca25e7e7..348410b287 100644 --- a/apps/admin-x-activitypub/src/components/Activities.tsx +++ b/apps/admin-x-activitypub/src/components/Activities.tsx @@ -2,51 +2,112 @@ import APAvatar from './global/APAvatar'; import ActivityItem from './activities/ActivityItem'; import MainNavigation from './navigation/MainNavigation'; import React from 'react'; +import {Button} from '@tryghost/admin-x-design-system'; +import {useBrowseInboxForUser, useFollowersForUser} from '../MainContent'; interface ActivitiesProps {} +// eslint-disable-next-line no-shadow +enum ACTVITY_TYPE { + LIKE = 'Like', + FOLLOW = 'Follow' +} + +type Actor = { + id: string + name: string + preferredUsername: string + url: string +} + +type ActivityObject = { + name: string + url: string +} + +type Activity = { + id: string + type: ACTVITY_TYPE + object?: ActivityObject + actor: Actor +} + +const getActorUsername = (actor: Actor): string => { + const url = new URL(actor.url); + const domain = url.hostname; + + return `@${actor.preferredUsername}@${domain}`; +}; + +const getActivityDescription = (activity: Activity): string => { + switch (activity.type) { + case ACTVITY_TYPE.FOLLOW: + return 'Followed you'; + case ACTVITY_TYPE.LIKE: + if (activity.object) { + return `Liked your article "${activity.object.name}"`; + } + } + + return ''; +}; + +const getActivityUrl = (activity: Activity): string | null => { + if (activity.object) { + return activity.object.url; + } + + return null; +}; + +const isFollower = (id: string, followerIds: string[]): boolean => { + return followerIds.includes(id); +}; + const Activities: React.FC = ({}) => { - // const fakeAuthor = + const user = 'index'; + const {data: activityData} = useBrowseInboxForUser(user); + const activities = (activityData || []) + .filter((activity) => { + return [ACTVITY_TYPE.FOLLOW, ACTVITY_TYPE.LIKE].includes(activity.type); + }) + .reverse(); // Endpoint currently returns items oldest-newest + const {data: followerData} = useFollowersForUser(user); + const followers = followerData || []; + return ( <>
-
- - -
-
Lydia Mango @username@domain.com
-
Followed you
-
-
+ {activities.length === 0 && ( +
This is an empty state when there are no activities
+ )} + {activities.length > 0 && ( +
+ {activities?.map(activity => ( + + +
+
+ {activity.actor.name} + {getActorUsername(activity.actor)} +
+
{getActivityDescription(activity)}
+
+ {isFollower(activity.actor.id, followers) === false && ( +
+ alert('Implement me!'); + }} /> + )} + + ))} +
+ )}
); }; -export default Activities; \ No newline at end of file +export default Activities; diff --git a/apps/admin-x-activitypub/src/components/Inbox.tsx b/apps/admin-x-activitypub/src/components/Inbox.tsx index 879b162b9f..f8fb9b8e22 100644 --- a/apps/admin-x-activitypub/src/components/Inbox.tsx +++ b/apps/admin-x-activitypub/src/components/Inbox.tsx @@ -86,4 +86,4 @@ const Inbox: React.FC = ({}) => { ); }; -export default Inbox; \ No newline at end of file +export default Inbox; diff --git a/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx b/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx index 0a2a12b887..ee17d69d80 100644 --- a/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx +++ b/apps/admin-x-activitypub/src/components/activities/ActivityItem.tsx @@ -9,13 +9,14 @@ export type Activity = { interface ActivityItemProps { children?: ReactNode; + url?: string | null; } -const ActivityItem: React.FC = ({children}) => { +const ActivityItem: React.FC = ({children, url = null}) => { const childrenArray = React.Children.toArray(children); - return ( -
+ const Item = ( +
{childrenArray[0]} {childrenArray[1]} @@ -23,6 +24,16 @@ const ActivityItem: React.FC = ({children}) => {
); + + if (url) { + return ( + + {Item} + + ); + } + + return Item; }; export default ActivityItem;