0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-03 23:00:14 -05:00

Updated ActivityPub collection retrieval to accommodate pagination (#21393)

refs
[AP-526](https://linear.app/ghost/issue/AP-526/implement-pagination-for-fedify-collections)

Updated followers, following, outbox and liked collection retrieval to
accommodate pagination
This commit is contained in:
Michael Barrett 2024-10-29 09:46:43 +00:00 committed by GitHub
parent 4b32a3d9c3
commit e6df621436
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 271 additions and 177 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@tryghost/admin-x-activitypub",
"version": "0.3.1",
"version": "0.3.2",
"license": "MIT",
"repository": {
"type": "git",

View file

@ -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'
}
}
];

View file

@ -92,35 +92,77 @@ export class ActivityPubAPI {
}
async getOutbox(): Promise<Activity[]> {
const json = await this.fetchJSON(this.outboxApiUrl);
if (json === null) {
const fetchOutboxPage = async (url: URL): Promise<Activity[]> => {
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<Activity[]> {
const json = await this.fetchJSON(this.followingApiUrl);
if (json === null) {
async getFollowing(): Promise<Actor[]> {
const fetchFollowingPage = async (url: URL): Promise<Actor[]> => {
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<number> {
@ -138,15 +180,39 @@ export class ActivityPubAPI {
return new URL(`.ghost/activitypub/followers/${this.handle}`, this.apiUrl);
}
async getFollowers(): Promise<Activity[]> {
const json = await this.fetchJSON(this.followersApiUrl);
if (json === null) {
async getFollowers(): Promise<Actor[]> {
const fetchFollowersPage = async (url: URL): Promise<Actor[]> => {
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<number> {
@ -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<Activity[]> {
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<GetFollowersForProfileResponse> {
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<Activity[]> => {
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<void> {

View file

@ -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<ProfileProps> = ({}) => {
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');

View file

@ -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,