From 8d2f06f0e9e9493bc5d7a627f843d4306ca5689f Mon Sep 17 00:00:00 2001 From: Michael Barrett Date: Tue, 29 Oct 2024 16:16:03 +0000 Subject: [PATCH] wip --- .../src/api/activitypub.ts | 75 +++++++++++++- .../src/components/Profile.tsx | 3 +- .../src/components/feed/FeedItem.tsx | 4 +- .../components/global/ViewProfileModal.tsx | 98 +++++++++++++++---- .../src/hooks/useActivityPubQueries.ts | 25 +++++ 5 files changed, 182 insertions(+), 23 deletions(-) diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts index 569775c8de..74a4b91e27 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 class ActivityPubAPI { constructor( private readonly apiUrl: URL, @@ -288,6 +292,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); 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 { + const fetchPosts = async (url: URL): Promise => { + 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); + } } diff --git a/apps/admin-x-activitypub/src/components/Profile.tsx b/apps/admin-x-activitypub/src/components/Profile.tsx index dc63f944a8..febfd459a1 100644 --- a/apps/admin-x-activitypub/src/components/Profile.tsx +++ b/apps/admin-x-activitypub/src/components/Profile.tsx @@ -17,6 +17,7 @@ import { useFollowingForUser, useLikedForUser, useOutboxForUser, + usePostsForUser, useUserDataForUser } from '../hooks/useActivityPubQueries'; @@ -28,7 +29,7 @@ const Profile: React.FC = ({}) => { 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}; diff --git a/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx b/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx index b22ec114f6..f8a17e8cb2 100644 --- a/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx +++ b/apps/admin-x-activitypub/src/components/feed/FeedItem.tsx @@ -470,8 +470,8 @@ const FeedItem: React.FC = ({actor, object, layout, type, comment {object.name ? object.name : ( 30 - ? stripHtml(object.content).substring(0, 50) + '...' + __html: object.content.length > 30 + ? stripHtml(object.content).substring(0, 50) + '...' : stripHtml(object.content) }}> )} diff --git a/apps/admin-x-activitypub/src/components/global/ViewProfileModal.tsx b/apps/admin-x-activitypub/src/components/global/ViewProfileModal.tsx index 2813e3d275..abd50e9f73 100644 --- a/apps/admin-x-activitypub/src/components/global/ViewProfileModal.tsx +++ b/apps/admin-x-activitypub/src/components/global/ViewProfileModal.tsx @@ -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(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]); + + return ( +
+ { + posts.length === 0 && !isLoading ? ( + + {`${handle} has no posts yet`} + + ) : ( + <> + {posts.map((post, index) => ( +
+ {}} + /> + {index < posts.length - 1 && ( +
+ )} +
+ ))} + + ) + } +
+ { + (isFetchingNextPage || isLoading) && ( +
+ +
+ ) + } +
+ ); +}; + interface ViewProfileModalProps { profile: { actor: ActorProperties; @@ -168,30 +245,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 4901da6fe2..669109d334 100644 --- a/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts +++ b/apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts @@ -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'); + } + }); +}