0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2024-12-30 22:34:01 -05:00
This commit is contained in:
Michael Barrett 2024-10-29 16:16:03 +00:00
parent 4a8da45895
commit 8d2f06f0e9
No known key found for this signature in database
5 changed files with 182 additions and 23 deletions

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 class ActivityPubAPI {
constructor(
private readonly apiUrl: URL,
@ -288,6 +292,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<void> {
const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl);
await this.fetchJSON(url, 'POST');
@ -509,4 +544,42 @@ export class ActivityPubAPI {
const json = await this.fetchJSON(url);
return json as Profile;
}
async getAllPosts(handle: string): Promise<Activity[]> {
const fetchPosts = async (url: URL): Promise<Activity[]> => {
const json = await this.fetchJSON(url);
// If the response is null, return early
if (json === null) {
return [];
}
// If the response doesn't have a posts array, return early
if (!('posts' in json)) {
return [];
}
// If the response has an items property, but it's not an array
// use an empty array
const items = Array.isArray(json.posts) ? json.posts : [];
// If the response has a next property, fetch the next page
// recursively and concatenate the results
if ('next' in json && typeof json.next === 'string') {
const nextUrl = new URL(url);
nextUrl.searchParams.set('next', json.next);
const nextItems = await fetchPosts(nextUrl);
return items.concat(nextItems);
}
return items;
};
const url = new URL(`.ghost/activitypub/profile/${handle}/posts`, this.apiUrl);
return fetchPosts(url);
}
}

View file

@ -17,6 +17,7 @@ import {
useFollowingForUser,
useLikedForUser,
useOutboxForUser,
usePostsForUser,
useUserDataForUser
} from '../hooks/useActivityPubQueries';
@ -28,7 +29,7 @@ const Profile: React.FC<ProfileProps> = ({}) => {
const {data: following = []} = useFollowingForUser('index');
const {data: followers = []} = useFollowersForUser('index');
const {data: liked = []} = useLikedForUser('index');
const {data: posts = []} = useOutboxForUser('index');
const {data: posts = []} = usePostsForUser('index');
// Replace 'index' with the actual handle of the user
const {data: userProfile} = useUserDataForUser('index') as {data: ActorProperties | null};

View file

@ -470,8 +470,8 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
<Heading className='line-clamp-1 font-semibold leading-normal' level={5} data-test-activity-heading>
{object.name ? object.name : (
<span dangerouslySetInnerHTML={{
__html: object.content.length > 30
? stripHtml(object.content).substring(0, 50) + '...'
__html: object.content.length > 30
? stripHtml(object.content).substring(0, 50) + '...'
: stripHtml(object.content)
}}></span>
)}

View file

@ -7,7 +7,7 @@ import {Button, Heading, Icon, List, LoadingIndicator, Modal, NoValueLabel, Tab,
import {UseInfiniteQueryResult} from '@tanstack/react-query';
import {type GetFollowersForProfileResponse, type GetFollowingForProfileResponse} from '../../api/activitypub';
import {useFollowersForProfile, useFollowingForProfile, useProfileForUser} from '../../hooks/useActivityPubQueries';
import {useFollowersForProfile, useFollowingForProfile, useProfileForUser, usePostsForProfile} from '../../hooks/useActivityPubQueries';
import APAvatar from '../global/APAvatar';
import ActivityItem from '../activities/ActivityItem';
@ -136,6 +136,83 @@ const FollowingTab: React.FC<{handle: string}> = ({handle}) => {
);
};
const PostsTab: React.FC<{handle: string}> = ({handle}) => {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading
} = usePostsForProfile(handle);
const posts = (data?.pages.flatMap(page => page.posts) ?? []);
// 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);
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]);
return (
<div>
{
posts.length === 0 && !isLoading ? (
<NoValueLabel icon='user-edit'>
{`${handle} has no posts yet`}
</NoValueLabel>
) : (
<>
{posts.map((post, index) => (
<div>
<FeedItem
actor={post.actor}
comments={post.object.replies}
layout='feed'
object={post.object}
type={post.type}
onCommentClick={() => {}}
/>
{index < posts.length - 1 && (
<div className="h-px w-full bg-grey-200"></div>
)}
</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;
@ -168,30 +245,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}
comments={post.object.replies}
layout='feed'
object={post.object}
type={post.type}
onCommentClick={() => {}}
/>
{index < posts.length - 1 && (
<div className="h-px w-full bg-grey-200"></div>
)}
</div>
))}
</div>
<PostsTab handle={profile.handle} />
)
},
{

View file

@ -314,6 +314,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}];
@ -374,3 +388,14 @@ export function useOutboxForUser(handle: string) {
}
});
}
export function usePostsForUser(handle: string) {
return useQuery({
queryKey: [`posts:${handle}`],
async queryFn() {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getAllPosts('@index@mikebook-pro.tail5da2a.ts.net');
}
});
}