0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

Utilised endpoint for retireving profile posts in admin-x-activitypub (#21767)

no refs

Updated the profile modal in admin-x-activitypub to retrieve profile
posts from the dedicated profile posts endpoint
This commit is contained in:
Michael Barrett 2024-11-29 23:37:35 +00:00 committed by GitHub
parent 2d072c2758
commit e2bccbc49f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 369 additions and 31 deletions

View file

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

View file

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

View file

@ -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<GetPostsForProfileResponse> {
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<Actor> {
const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl);
const json = await this.fetchJSON(url, 'POST');

View file

@ -46,8 +46,6 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
return !activity.object.inReplyTo;
});
// Intersection observer to fetch more activities when the user scrolls
// to the bottom of the page
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);

View file

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

View file

@ -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<ActorListProps> = ({
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<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(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<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(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 (
<div>
{posts.map((post, index) => (
<div>
<FeedItem
actor={post.actor}
commentCount={post.object.replyCount}
layout='feed'
object={post.object}
type={post.type}
onCommentClick={() => {}}
/>
{index < posts.length - 1 && (
<Separator />
)}
</div>
))}
<div ref={loadMoreRef} className='h-1'></div>
{
(isFetchingNextPage || isLoading) && (
<div className='mt-6 flex flex-col items-center justify-center space-y-4 text-center'>
<LoadingIndicator size='md' />
</div>
)
}
</div>
);
};
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<ViewProfileModalProps> = ({
}
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: (
<div>
{posts.map((post, index) => (
<div>
<FeedItem
actor={profile.actor}
commentCount={post.object.replyCount}
layout='feed'
object={post.object}
type={post.type}
onCommentClick={() => {}}
/>
{index < posts.length - 1 && (
<Separator />
)}
</div>
))}
</div>
<PostsTab handle={profile.handle} />
)
},
{

View file

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