diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index c3ec1ed59b..cd4712c6d4 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/admin-x-activitypub", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "repository": { "type": "git", diff --git a/apps/admin-x-activitypub/src/api/activitypub.test.ts b/apps/admin-x-activitypub/src/api/activitypub.test.ts index 8eacb186ec..fcd88370c1 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.test.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.test.ts @@ -199,7 +199,7 @@ describe('ActivityPubAPI', function () { }, response: JSONResponse({ type: 'Collection', - items: [] + orderedItems: [] }) } }); @@ -224,8 +224,13 @@ describe('ActivityPubAPI', function () { }, 'https://activitypub.api/.ghost/activitypub/outbox/index': { response: JSONResponse({ - type: 'Collection', - items: [] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/outbox/index?cursor=0' + }) + }, + 'https://activitypub.api/.ghost/activitypub/outbox/index?cursor=0': { + response: JSONResponse({ + type: 'OrderedCollection' }) } }); @@ -242,7 +247,7 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); - test('Returns all the items array when the outbox is not empty', async function () { + test('Recursively retrieves all items and returns them when the outbox is not empty', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -254,14 +259,32 @@ describe('ActivityPubAPI', function () { 'https://activitypub.api/.ghost/activitypub/outbox/index': { response: JSONResponse({ - type: 'Collection', - orderedItems: [{ - type: 'Create', - object: { - type: 'Note' - } - }] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/outbox/index?cursor=0' }) + }, + 'https://activitypub.api/.ghost/activitypub/outbox/index?cursor=0': { + response: JSONResponse({ + type: 'OrderedCollection', + next: 'https://activitypub.api/.ghost/activitypub/outbox/index?cursor=1', + orderedItems: [{ + type: 'Create', + object: { + type: 'Note' + } + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/outbox/index?cursor=1': { + response: JSONResponse({ + type: 'OrderedCollection', + orderedItems: [{ + type: 'Create', + object: { + type: 'Article' + } + }] + }) } }); @@ -279,48 +302,11 @@ describe('ActivityPubAPI', function () { object: { type: 'Note' } - } - ]; - - expect(actual).toEqual(expected); - }); - - test('Returns an array when the orderedItems key is a single object', async function () { - const fakeFetch = Fetch({ - 'https://auth.api/': { - response: JSONResponse({ - identities: [{ - token: 'fake-token' - }] - }) }, - 'https://activitypub.api/.ghost/activitypub/outbox/index': { - response: - JSONResponse({ - type: 'Collection', - orderedItems: { - type: 'Create', - object: { - type: 'Note' - } - } - }) - } - }); - - const api = new ActivityPubAPI( - new URL('https://activitypub.api'), - new URL('https://auth.api'), - 'index', - fakeFetch - ); - - const actual = await api.getOutbox(); - const expected: Activity[] = [ { type: 'Create', object: { - type: 'Note' + type: 'Article' } } ]; @@ -371,8 +357,13 @@ describe('ActivityPubAPI', function () { }, 'https://activitypub.api/.ghost/activitypub/following/index': { response: JSONResponse({ - type: 'Collection', - items: [] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/following/index?cursor=0' + }) + }, + 'https://activitypub.api/.ghost/activitypub/following/index?cursor=0': { + response: JSONResponse({ + type: 'OrderedCollection' }) } }); @@ -389,7 +380,7 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); - test('Returns all the items array when the following is not empty', async function () { + test('Recursively retrieves all items and returns them when the following is not empty', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -401,11 +392,26 @@ describe('ActivityPubAPI', function () { 'https://activitypub.api/.ghost/activitypub/following/index': { response: JSONResponse({ - type: 'Collection', - orderedItems: [{ - type: 'Person' - }] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/following/index?cursor=0' }) + }, + 'https://activitypub.api/.ghost/activitypub/following/index?cursor=0': { + response: JSONResponse({ + type: 'OrderedCollection', + next: 'https://activitypub.api/.ghost/activitypub/following/index?cursor=1', + orderedItems: [{ + type: 'Person' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/following/index?cursor=1': { + response: JSONResponse({ + type: 'OrderedCollection', + orderedItems: [{ + type: 'Group' + }] + }) } }); @@ -420,43 +426,9 @@ describe('ActivityPubAPI', function () { const expected: Activity[] = [ { type: 'Person' - } - ]; - - 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/following/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.getFollowing(); - const expected: Activity[] = [ { - type: 'Person' + type: 'Group' } ]; @@ -465,7 +437,7 @@ describe('ActivityPubAPI', function () { }); describe('getFollowers', function () { - test('It passes the token to the followers endpoint', async function () { + test('It passes the token to the following endpoint', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -506,8 +478,13 @@ describe('ActivityPubAPI', function () { }, 'https://activitypub.api/.ghost/activitypub/followers/index': { response: JSONResponse({ - type: 'Collection', - orderedItems: [] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/followers/index?cursor=0' + }) + }, + 'https://activitypub.api/.ghost/activitypub/followers/index?cursor=0': { + response: JSONResponse({ + type: 'OrderedCollection' }) } }); @@ -524,7 +501,7 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); - test('Returns all the items array when the followers is not empty', async function () { + test('Recursively retrieves all items and returns them when the followers is not empty', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -536,11 +513,26 @@ describe('ActivityPubAPI', function () { 'https://activitypub.api/.ghost/activitypub/followers/index': { response: JSONResponse({ - type: 'Collection', - orderedItems: [{ - type: 'Person' - }] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/followers/index?cursor=0' }) + }, + 'https://activitypub.api/.ghost/activitypub/followers/index?cursor=0': { + response: JSONResponse({ + type: 'OrderedCollection', + next: 'https://activitypub.api/.ghost/activitypub/followers/index?cursor=1', + orderedItems: [{ + type: 'Person' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/followers/index?cursor=1': { + response: JSONResponse({ + type: 'OrderedCollection', + orderedItems: [{ + type: 'Group' + }] + }) } }); @@ -555,6 +547,9 @@ describe('ActivityPubAPI', function () { const expected: Activity[] = [ { type: 'Person' + }, + { + type: 'Group' } ]; @@ -562,8 +557,8 @@ describe('ActivityPubAPI', function () { }); }); - describe('getFollowersExpanded', function () { - test('It passes the token to the followers endpoint', async function () { + describe('getLiked', function () { + test('It passes the token to the liked endpoint', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -572,7 +567,7 @@ describe('ActivityPubAPI', function () { }] }) }, - 'https://activitypub.api/.ghost/activitypub/followers-expanded/index': { + 'https://activitypub.api/.ghost/activitypub/liked/index': { async assert(_resource, init) { const headers = new Headers(init?.headers); expect(headers.get('Authorization')).toContain('fake-token'); @@ -590,10 +585,10 @@ describe('ActivityPubAPI', function () { fakeFetch ); - await api.getFollowersExpanded(); + await api.getLiked(); }); - test('Returns an empty array when the followers is empty', async function () { + test('Returns an empty array when the liked collection is empty', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -602,10 +597,15 @@ describe('ActivityPubAPI', function () { }] }) }, - 'https://activitypub.api/.ghost/activitypub/followers-expanded/index': { + 'https://activitypub.api/.ghost/activitypub/liked/index': { response: JSONResponse({ - type: 'Collection', - orderedItems: [] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/liked/index?cursor=0' + }) + }, + 'https://activitypub.api/.ghost/activitypub/liked/index?cursor=0': { + response: JSONResponse({ + type: 'OrderedCollection' }) } }); @@ -616,13 +616,13 @@ describe('ActivityPubAPI', function () { fakeFetch ); - const actual = await api.getFollowersExpanded(); + const actual = await api.getLiked(); const expected: never[] = []; expect(actual).toEqual(expected); }); - test('Returns all the items array when the followers is not empty', async function () { + test('Recursively retrieves all items and returns them when the liked collection is not empty', async function () { const fakeFetch = Fetch({ 'https://auth.api/': { response: JSONResponse({ @@ -631,14 +631,35 @@ describe('ActivityPubAPI', function () { }] }) }, - 'https://activitypub.api/.ghost/activitypub/followers-expanded/index': { + 'https://activitypub.api/.ghost/activitypub/liked/index': { response: JSONResponse({ - type: 'Collection', - orderedItems: [{ - type: 'Person' - }] + type: 'OrderedCollection', + first: 'https://activitypub.api/.ghost/activitypub/liked/index?cursor=0' }) + }, + 'https://activitypub.api/.ghost/activitypub/liked/index?cursor=0': { + response: JSONResponse({ + type: 'OrderedCollection', + next: 'https://activitypub.api/.ghost/activitypub/liked/index?cursor=1', + orderedItems: [{ + type: 'Create', + object: { + type: 'Note' + } + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/liked/index?cursor=1': { + response: JSONResponse({ + type: 'OrderedCollection', + orderedItems: [{ + type: 'Create', + object: { + type: 'Article' + } + }] + }) } }); @@ -649,10 +670,19 @@ describe('ActivityPubAPI', function () { fakeFetch ); - const actual = await api.getFollowersExpanded(); + const actual = await api.getLiked(); const expected: Activity[] = [ { - type: 'Person' + type: 'Create', + object: { + type: 'Note' + } + }, + { + type: 'Create', + object: { + type: 'Article' + } } ]; diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts index 3f70a540da..569775c8de 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.ts @@ -92,35 +92,77 @@ export class ActivityPubAPI { } async getOutbox(): Promise { - const json = await this.fetchJSON(this.outboxApiUrl); - if (json === null) { + const fetchOutboxPage = async (url: URL): Promise => { + const json = await this.fetchJSON(url); + + if (json === null) { + return []; + } + + let items: Activity[] = []; + + if ('orderedItems' in json) { + items = Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; + } + + if ('next' in json && typeof json.next === 'string') { + const nextUrl = new URL(json.next); + const nextItems = await fetchOutboxPage(nextUrl); + + items = items.concat(nextItems); + } + + return items; + }; + + const initialJson = await this.fetchJSON(this.outboxApiUrl); + + if (initialJson === null || !('first' in initialJson) || typeof initialJson.first !== 'string') { return []; } - if ('orderedItems' in json) { - return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; - } - if ('items' in json) { - return Array.isArray(json.items) ? json.items : [json.items]; - } - return []; + + const firstPageUrl = new URL(initialJson.first); + + return fetchOutboxPage(firstPageUrl); } get followingApiUrl() { return new URL(`.ghost/activitypub/following/${this.handle}`, this.apiUrl); } - async getFollowing(): Promise { - const json = await this.fetchJSON(this.followingApiUrl); - if (json === null) { + async getFollowing(): Promise { + const fetchFollowingPage = async (url: URL): Promise => { + const json = await this.fetchJSON(url); + + if (json === null) { + return []; + } + + let items: Actor[] = []; + + if ('orderedItems' in json) { + items = Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; + } + + if ('next' in json && typeof json.next === 'string') { + const nextUrl = new URL(json.next); + const nextItems = await fetchFollowingPage(nextUrl); + + items = items.concat(nextItems); + } + + return items; + }; + + const initialJson = await this.fetchJSON(this.followingApiUrl); + + if (initialJson === null || !('first' in initialJson) || typeof initialJson.first !== 'string') { return []; } - if ('orderedItems' in json) { - return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; - } - if ('items' in json) { - return Array.isArray(json.items) ? json.items : [json.items]; - } - return []; + + const firstPageUrl = new URL(initialJson.first); + + return fetchFollowingPage(firstPageUrl); } async getFollowingCount(): Promise { @@ -138,15 +180,39 @@ export class ActivityPubAPI { return new URL(`.ghost/activitypub/followers/${this.handle}`, this.apiUrl); } - async getFollowers(): Promise { - const json = await this.fetchJSON(this.followersApiUrl); - if (json === null) { + async getFollowers(): Promise { + const fetchFollowersPage = async (url: URL): Promise => { + const json = await this.fetchJSON(url); + + if (json === null) { + return []; + } + + let items: Actor[] = []; + + if ('orderedItems' in json) { + items = Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; + } + + if ('next' in json && typeof json.next === 'string') { + const nextUrl = new URL(json.next); + const nextItems = await fetchFollowersPage(nextUrl); + + items = items.concat(nextItems); + } + + return items; + }; + + const initialJson = await this.fetchJSON(this.followersApiUrl); + + if (initialJson === null || !('first' in initialJson) || typeof initialJson.first !== 'string') { return []; } - if ('orderedItems' in json) { - return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; - } - return []; + + const firstPageUrl = new URL(initialJson.first); + + return fetchFollowersPage(firstPageUrl); } async getFollowersCount(): Promise { @@ -160,21 +226,6 @@ export class ActivityPubAPI { return 0; } - get followersExpandedApiUrl() { - return new URL(`.ghost/activitypub/followers-expanded/${this.handle}`, this.apiUrl); - } - - async getFollowersExpanded(): Promise { - const json = await this.fetchJSON(this.followersExpandedApiUrl); - if (json === null) { - return []; - } - if ('orderedItems' in json) { - return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; - } - return []; - } - async getFollowersForProfile(handle: string, next?: string): Promise { const url = new URL(`.ghost/activitypub/profile/${handle}/followers`, this.apiUrl); if (next) { @@ -252,14 +303,38 @@ export class ActivityPubAPI { } async getLiked() { - const json = await this.fetchJSON(this.likedApiUrl); - if (json === null) { + const fetchLikedPage = async (url: URL): Promise => { + const json = await this.fetchJSON(url); + + if (json === null) { + return []; + } + + let items: Activity[] = []; + + if ('orderedItems' in json) { + items = Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; + } + + if ('next' in json && typeof json.next === 'string') { + const nextUrl = new URL(json.next); + const nextItems = await fetchLikedPage(nextUrl); + + items = items.concat(nextItems); + } + + return items; + }; + + const initialJson = await this.fetchJSON(this.likedApiUrl); + + if (initialJson === null || !('first' in initialJson) || typeof initialJson.first !== 'string') { return []; } - if ('orderedItems' in json) { - return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; - } - return []; + + const firstPageUrl = new URL(initialJson.first); + + return fetchLikedPage(firstPageUrl); } async like(id: string): Promise { diff --git a/apps/admin-x-activitypub/src/components/Profile.tsx b/apps/admin-x-activitypub/src/components/Profile.tsx index 7edad06225..dc63f944a8 100644 --- a/apps/admin-x-activitypub/src/components/Profile.tsx +++ b/apps/admin-x-activitypub/src/components/Profile.tsx @@ -12,7 +12,7 @@ import ViewProfileModal from './global/ViewProfileModal'; import {Button, Heading, List, NoValueLabel, Tab, TabView} from '@tryghost/admin-x-design-system'; import { useFollowersCountForUser, - useFollowersExpandedForUser, + useFollowersForUser, useFollowingCountForUser, useFollowingForUser, useLikedForUser, @@ -26,7 +26,7 @@ const Profile: React.FC = ({}) => { const {data: followersCount = 0} = useFollowersCountForUser('index'); const {data: followingCount = 0} = useFollowingCountForUser('index'); const {data: following = []} = useFollowingForUser('index'); - const {data: followers = []} = useFollowersExpandedForUser('index'); + const {data: followers = []} = useFollowersForUser('index'); const {data: liked = []} = useLikedForUser('index'); const {data: posts = []} = useOutboxForUser('index'); diff --git a/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts b/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts index 6bc6026617..4901da6fe2 100644 --- a/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts +++ b/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts @@ -193,17 +193,6 @@ export function useFollowersForUser(handle: string) { }); } -export function useFollowersExpandedForUser(handle: string) { - return useQuery({ - queryKey: [`followers_expanded:${handle}`], - async queryFn() { - const siteUrl = await getSiteUrl(); - const api = createActivityPubAPI(handle, siteUrl); - return api.getFollowersExpanded(); - } - }); -} - export function useAllActivitiesForUser({ handle, includeOwn = false,