diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index 7e55eabc9a..80012bb5f6 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.28", + "version": "0.3.29", "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 5c64da3623..70df82f4b1 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.test.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.test.ts @@ -1011,7 +1011,7 @@ describe('ActivityPubAPI', function () { }, [`https://activitypub.api/.ghost/activitypub/profile/${handle}/followers`]: { response: JSONResponse({ - followers: {} + followers: [] }) } }); @@ -1245,7 +1245,7 @@ describe('ActivityPubAPI', function () { }, [`https://activitypub.api/.ghost/activitypub/profile/${handle}/following`]: { response: JSONResponse({ - following: {} + following: [] }) } }); @@ -1263,6 +1263,252 @@ describe('ActivityPubAPI', function () { }); }); + describe('getPostsForProfile', function () { + test('It returns an array of posts for a profile', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/posts`]: { + response: JSONResponse({ + posts: [ + { + actor: { + id: 'https://example.com/users/bar' + }, + object: { + content: 'Hello, world!' + } + }, + { + actor: { + id: 'https://example.com/users/baz' + }, + object: { + content: 'Hello, world again!' + } + } + ], + next: null + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getPostsForProfile(handle); + + expect(actual.posts).toEqual([ + { + actor: { + id: 'https://example.com/users/bar' + }, + object: { + content: 'Hello, world!' + } + }, + { + actor: { + id: 'https://example.com/users/baz' + }, + object: { + content: 'Hello, world again!' + } + } + ]); + }); + + test('It returns next if it is present in the response', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/posts`]: { + response: JSONResponse({ + posts: [], + next: 'abc123' + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getPostsForProfile(handle); + + expect(actual.next).toEqual('abc123'); + }); + + test('It includes next in the query when provided', async function () { + const handle = '@foo@bar.baz'; + const next = 'abc123'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/posts?next=${next}`]: { + response: JSONResponse({ + posts: [ + { + actor: { + id: 'https://example.com/users/bar' + }, + object: { + content: 'Hello, world!' + } + } + ], + next: null + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getPostsForProfile(handle, next); + const expected = { + posts: [ + { + actor: { + id: 'https://example.com/users/bar' + }, + object: { + content: 'Hello, world!' + } + } + ], + next: null + }; + + expect(actual).toEqual(expected); + }); + + test('It returns a default return value when the response is null', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/posts`]: { + response: JSONResponse(null) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getPostsForProfile(handle); + const expected = { + posts: [], + next: null + }; + + expect(actual).toEqual(expected); + }); + + test('It returns a default return value if followers is not present in the response', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/posts`]: { + response: JSONResponse({}) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getPostsForProfile(handle); + const expected = { + posts: [], + next: null + }; + + expect(actual).toEqual(expected); + }); + + test('It returns an empty array of followers if followers in the response is not an array', async function () { + const handle = '@foo@bar.baz'; + + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/profile/${handle}/posts`]: { + response: JSONResponse({ + posts: [] + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getPostsForProfile(handle); + + expect(actual.posts).toEqual([]); + }); + }); + describe('getProfile', function () { test('It returns a profile', async function () { const handle = '@foo@bar.baz'; diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts index 11a31876a1..c2837a9358 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.ts @@ -9,7 +9,6 @@ export interface Profile { followerCount: number; followingCount: number; isFollowing: boolean; - posts: Activity[]; } export interface SearchResults { @@ -32,6 +31,11 @@ export interface GetFollowingForProfileResponse { next: string | null; } +export interface GetPostsForProfileResponse { + posts: Activity[]; + next: string | null; +} + export interface ActivityThread { items: Activity[]; } @@ -234,6 +238,37 @@ export class ActivityPubAPI { }; } + async getPostsForProfile(handle: string, next?: string): Promise { + const url = new URL(`.ghost/activitypub/profile/${handle}/posts`, this.apiUrl); + if (next) { + url.searchParams.set('next', next); + } + + const json = await this.fetchJSON(url); + + if (json === null) { + return { + posts: [], + next: null + }; + } + + if (!('posts' in json)) { + return { + posts: [], + next: null + }; + } + + const posts = Array.isArray(json.posts) ? json.posts : []; + const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null; + + return { + posts, + next: nextPage + }; + } + async follow(username: string): Promise { const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl); const json = await this.fetchJSON(url, 'POST'); diff --git a/apps/admin-x-activitypub/src/components/Inbox.tsx b/apps/admin-x-activitypub/src/components/Inbox.tsx index 7979e2e15f..fb340f5dda 100644 --- a/apps/admin-x-activitypub/src/components/Inbox.tsx +++ b/apps/admin-x-activitypub/src/components/Inbox.tsx @@ -46,8 +46,6 @@ const Inbox: React.FC = ({layout}) => { return !activity.object.inReplyTo; }); - // Intersection observer to fetch more activities when the user scrolls - // to the bottom of the page const observerRef = useRef(null); const loadMoreRef = useRef(null); diff --git a/apps/admin-x-activitypub/src/components/Search.tsx b/apps/admin-x-activitypub/src/components/Search.tsx index cc2ce7b803..d235938205 100644 --- a/apps/admin-x-activitypub/src/components/Search.tsx +++ b/apps/admin-x-activitypub/src/components/Search.tsx @@ -1,6 +1,6 @@ import React, {useEffect, useRef, useState} from 'react'; -import {Activity, ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; +import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Button, Icon, LoadingIndicator, NoValueLabel, TextField} from '@tryghost/admin-x-design-system'; import {useDebounce} from 'use-debounce'; @@ -22,7 +22,6 @@ interface SearchResultItem { followerCount: number; followingCount: number; isFollowing: boolean; - posts: Activity[]; } interface SearchResultProps { diff --git a/apps/admin-x-activitypub/src/components/global/ViewProfileModal.tsx b/apps/admin-x-activitypub/src/components/global/ViewProfileModal.tsx index b219dc8220..48000cd849 100644 --- a/apps/admin-x-activitypub/src/components/global/ViewProfileModal.tsx +++ b/apps/admin-x-activitypub/src/components/global/ViewProfileModal.tsx @@ -1,13 +1,13 @@ import React, {useEffect, useRef, useState} from 'react'; import NiceModal, {useModal} from '@ebay/nice-modal-react'; -import {Activity, ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; +import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {Button, Heading, Icon, List, LoadingIndicator, Modal, NoValueLabel, Tab,TabView} from '@tryghost/admin-x-design-system'; import {UseInfiniteQueryResult} from '@tanstack/react-query'; import {type GetFollowersForProfileResponse, type GetFollowingForProfileResponse} from '../../api/activitypub'; -import {useFollowersForProfile, useFollowingForProfile, useProfileForUser} from '../../hooks/useActivityPubQueries'; +import {useFollowersForProfile, useFollowingForProfile, usePostsForProfile, useProfileForUser} from '../../hooks/useActivityPubQueries'; import APAvatar from '../global/APAvatar'; import ActivityItem from '../activities/ActivityItem'; @@ -46,8 +46,6 @@ const ActorList: React.FC = ({ const actorData = (data?.pages.flatMap(resolveDataFn) ?? []); - // Intersection observer to fetch more data when the user scrolls - // to the bottom of the list const observerRef = useRef(null); const loadMoreRef = useRef(null); @@ -141,6 +139,72 @@ const FollowingTab: React.FC<{handle: string}> = ({handle}) => { ); }; +const PostsTab: React.FC<{handle: string}> = ({handle}) => { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading + } = usePostsForProfile(handle); + + const observerRef = useRef(null); + const loadMoreRef = useRef(null); + + useEffect(() => { + if (observerRef.current) { + observerRef.current.disconnect(); + } + + observerRef.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }); + + if (loadMoreRef.current) { + observerRef.current.observe(loadMoreRef.current); + } + + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const posts = (data?.pages.flatMap(page => page.posts) ?? []) + .filter(post => post.type === 'Create' && !post.object.inReplyTo); + + return ( +
+ {posts.map((post, index) => ( +
+ {}} + /> + {index < posts.length - 1 && ( + + )} +
+ ))} +
+ { + (isFetchingNextPage || isLoading) && ( +
+ +
+ ) + } +
+ ); +}; + interface ViewProfileModalProps { profile: { actor: ActorProperties; @@ -148,7 +212,6 @@ interface ViewProfileModalProps { followerCount: number; followingCount: number; isFollowing: boolean; - posts: Activity[]; } | string; onFollow: () => void; onUnfollow: () => void; @@ -173,30 +236,13 @@ const ViewProfileModal: React.FC = ({ } const attachments = (profile?.actor.attachment || []); - const posts = (profile?.posts || []).filter(post => post.type !== 'Announce'); const tabs = isLoading === false && typeof profile !== 'string' && profile ? [ { id: 'posts', title: 'Posts', contents: ( -
- {posts.map((post, index) => ( -
- {}} - /> - {index < posts.length - 1 && ( - - )} -
- ))} -
+ ) }, { diff --git a/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts b/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts index 317c24f94a..90c064cba4 100644 --- a/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts +++ b/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts @@ -348,6 +348,20 @@ export function useFollowingForProfile(handle: string) { }); } +export function usePostsForProfile(handle: string) { + return useInfiniteQuery({ + queryKey: [`posts:${handle}`], + async queryFn({pageParam}: {pageParam?: string}) { + const siteUrl = await getSiteUrl(); + const api = createActivityPubAPI(handle, siteUrl); + return api.getPostsForProfile(handle, pageParam); + }, + getNextPageParam(prevPage) { + return prevPage.next; + } + }); +} + export function useSuggestedProfiles(handle: string, handles: string[]) { const queryClient = useQueryClient(); const queryKey = ['profiles', {handles}];