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:
parent
2d072c2758
commit
e2bccbc49f
7 changed files with 369 additions and 31 deletions
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@tryghost/admin-x-activitypub",
|
||||
"version": "0.3.28",
|
||||
"version": "0.3.29",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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} />
|
||||
)
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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}];
|
||||
|
|
Loading…
Add table
Reference in a new issue