From d7ee3b2e42ca608a9033d65885c9b750db0f49d2 Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Wed, 18 Sep 2024 10:33:52 +0100 Subject: [PATCH] Updated activitypub app to utilise new activities endpoint (#21025) refs [AP-377](https://linear.app/tryghost/issue/AP-377/inbox-returning-33mb-of-data), [TryGhost/ActivityPub#40](https://github.com/TryGhost/ActivityPub/pull/40) Updated activitypub app to utilise new activities endpoint which returns a paginated list of activities --- .../src/api/activitypub.test.ts | 41 ++++++++++++++++ .../src/api/activitypub.ts | 48 +++++++++++++++++++ .../src/components/Inbox.tsx | 13 ++--- .../src/hooks/useActivityPubQueries.ts | 11 +++++ 4 files changed, 105 insertions(+), 8 deletions(-) diff --git a/apps/admin-x-activitypub/src/api/activitypub.test.ts b/apps/admin-x-activitypub/src/api/activitypub.test.ts index e60e1c251c..9eb89a2b15 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.test.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.test.ts @@ -443,4 +443,45 @@ describe('ActivityPubAPI', function () { await api.follow('@user@domain.com'); }); }); + + describe('getAllActivities', function () { + test('It fetches all activities navigating pagination', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/activities/index?limit=50': { + response: JSONResponse({ + items: [{type: 'Create', object: {type: 'Note'}}], + nextCursor: 'next-cursor' + }) + }, + 'https://activitypub.api/.ghost/activitypub/activities/index?limit=50&cursor=next-cursor': { + response: JSONResponse({ + items: [{type: 'Announce', object: {type: 'Article'}}], + nextCursor: null + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getAllActivities(); + const expected: Activity[] = [ + {type: 'Create', object: {type: 'Note'}}, + {type: 'Announce', object: {type: 'Article'}} + ]; + + expect(actual).toEqual(expected); + }); + }); }); diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts index 7065368999..ea953ea105 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.ts @@ -160,4 +160,52 @@ export class ActivityPubAPI { const url = new URL(`.ghost/activitypub/actions/unlike/${encodeURIComponent(id)}`, this.apiUrl); await this.fetchJSON(url, 'POST'); } + + get activitiesApiUrl() { + return new URL(`.ghost/activitypub/activities/${this.handle}`, this.apiUrl); + } + + async getAllActivities(): Promise { + const LIMIT = 50; + + const fetchActivities = async (url: URL): Promise => { + const json = await this.fetchJSON(url); + + // If the response is null, return early + if (json === null) { + return []; + } + + // If the response doesn't have an items array, return early + if (!('items' in json)) { + return []; + } + + // If the response has an items property, but it's not an array + // use an empty array + const items = Array.isArray(json.items) ? json.items : []; + + // If the response has a nextCursor property, fetch the next page + // recursively and concatenate the results + if ('nextCursor' in json && typeof json.nextCursor === 'string') { + const nextUrl = new URL(url); + + nextUrl.searchParams.set('cursor', json.nextCursor); + nextUrl.searchParams.set('limit', LIMIT.toString()); + + const nextItems = await fetchActivities(nextUrl); + + return items.concat(nextItems); + } + + return items; + }; + + // Make a copy of the activities API URL and set the limit + const url = new URL(this.activitiesApiUrl); + url.searchParams.set('limit', LIMIT.toString()); + + // Fetch the activities + return fetchActivities(url); + } } diff --git a/apps/admin-x-activitypub/src/components/Inbox.tsx b/apps/admin-x-activitypub/src/components/Inbox.tsx index 75506eb9f4..ad094a2e61 100644 --- a/apps/admin-x-activitypub/src/components/Inbox.tsx +++ b/apps/admin-x-activitypub/src/components/Inbox.tsx @@ -7,7 +7,7 @@ import React, {useState} from 'react'; import {type Activity} from './activities/ActivityItem'; import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Button, Heading} from '@tryghost/admin-x-design-system'; -import {useBrowseInboxForUser} from '../MainContent'; +import {useAllActivitiesForUser} from '../hooks/useActivityPubQueries'; interface InboxProps {} @@ -16,18 +16,15 @@ const Inbox: React.FC = ({}) => { const [, setArticleActor] = useState(null); const [layout, setLayout] = useState('inbox'); - // Retrieve activities from the inbox - const {data: inboxActivities = []} = useBrowseInboxForUser('index'); + // Retrieve all activities for the user + let {data: activities = []} = useAllActivitiesForUser('index'); - const activities = inboxActivities.filter((activity: Activity) => { + activities = activities.filter((activity: Activity) => { const isCreate = activity.type === 'Create' && ['Article', 'Note'].includes(activity.object.type); const isAnnounce = activity.type === 'Announce' && activity.object.type === 'Note'; return isCreate || isAnnounce; - }) - // API endpoint currently returns items oldest-newest, so reverse them - // to show the most recent activities first - .reverse(); + }); // Create a map of activity comments, grouping them by the parent activity // This allows us to quickly look up all comments for a given activity diff --git a/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts b/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts index e7062b28a8..ebb1da14f8 100644 --- a/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts +++ b/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts @@ -165,3 +165,14 @@ export function useFollowersForUser(handle: string) { } }); } + +export function useAllActivitiesForUser(handle: string) { + const siteUrl = useSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + return useQuery({ + queryKey: [`activities:${handle}`], + async queryFn() { + return api.getAllActivities(); + } + }); +}