mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-30 22:34:01 -05:00
wip
This commit is contained in:
parent
4a8da45895
commit
8d2f06f0e9
5 changed files with 182 additions and 23 deletions
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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} />
|
||||
)
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue