0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Updated profile tab to use dedicated account endpoints in admin-x-activitypub (#22010)

refs
[AP-647](https://linear.app/ghost/issue/AP-648/refactor-profile-tab-to-use-account-and-follows)

Updated the profile tab in `admin-x-activitypub` to use dedicated
account endpoints. This is to remove coupling between the UI and the
ActivityPub endpoints in preparation for the upcoming changes around
storing `accounts` and `follows` in the database
This commit is contained in:
Michael Barrett 2025-01-15 16:43:51 +00:00 committed by GitHub
parent 73f8bcf0b3
commit 6bc164cb7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 196 additions and 121 deletions

View file

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

View file

@ -43,6 +43,34 @@ export interface GetPostsForProfileResponse {
next: string | null;
}
export type AccountFollowsType = 'following' | 'followers';
interface Account {
id: string;
name: string;
handle: string;
bio: string;
url: string;
avatarUrl: string;
bannerImageUrl: string | null;
customFields: Record<string, string>;
postsCount: number;
likedCount: number;
followingCount: number;
followerCount: number;
followsMe: boolean;
followedByMe: boolean;
}
type GetAccountResponse = Account
export type MinimalAccount = Pick<Account, 'id' | 'name' | 'handle' | 'avatarUrl'>;
export interface GetAccountFollowsResponse {
accounts: MinimalAccount[];
next: string | null;
}
export class ActivityPubAPI {
constructor(
private readonly apiUrl: URL,
@ -114,20 +142,6 @@ export class ActivityPubAPI {
};
}
private async getActivityPubCollectionCount(collectionUrl: URL): Promise<number> {
const json = await this.fetchJSON(collectionUrl);
if (json === null) {
return 0;
}
if ('totalItems' in json && typeof json.totalItems === 'number') {
return json.totalItems;
}
return 0;
}
get inboxApiUrl() {
return new URL(`.ghost/activitypub/inbox/${this.handle}`, this.apiUrl);
}
@ -162,10 +176,6 @@ export class ActivityPubAPI {
return this.getActivityPubCollection<Actor>(this.followingApiUrl, cursor);
}
async getFollowingCount(): Promise<number> {
return this.getActivityPubCollectionCount(this.followingApiUrl);
}
get followersApiUrl() {
return new URL(`.ghost/activitypub/followers/${this.handle}`, this.apiUrl);
}
@ -174,10 +184,6 @@ export class ActivityPubAPI {
return this.getActivityPubCollection<Actor>(this.followersApiUrl, cursor);
}
async getFollowersCount(): Promise<number> {
return this.getActivityPubCollectionCount(this.followersApiUrl);
}
async follow(username: string): Promise<Actor> {
const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl);
const json = await this.fetchJSON(url, 'POST');
@ -192,10 +198,6 @@ export class ActivityPubAPI {
return this.getActivityPubCollection<Activity>(this.likedApiUrl, cursor);
}
async getLikedCount(): Promise<number> {
return this.getActivityPubCollectionCount(this.likedApiUrl);
}
async like(id: string): Promise<void> {
const url = new URL(`.ghost/activitypub/actions/like/${encodeURIComponent(id)}`, this.apiUrl);
await this.fetchJSON(url, 'POST');
@ -404,4 +406,46 @@ export class ActivityPubAPI {
const json = await this.fetchJSON(url);
return json as ActivityThread;
}
get accountApiUrl() {
return new URL(`.ghost/activitypub/account/${this.handle}`, this.apiUrl);
}
async getAccount(): Promise<GetAccountResponse> {
const json = await this.fetchJSON(this.accountApiUrl);
return json as GetAccountResponse;
}
async getAccountFollows(type: AccountFollowsType, next?: string): Promise<GetAccountFollowsResponse> {
const url = new URL(`.ghost/activitypub/account/${this.handle}/follows/${type}`, this.apiUrl);
if (next) {
url.searchParams.set('next', next);
}
const json = await this.fetchJSON(url);
if (json === null) {
return {
accounts: [],
next: null
};
}
if (!('accounts' in json)) {
return {
accounts: [],
next: null
};
}
const accounts = Array.isArray(json.accounts) ? json.accounts : [];
const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null;
return {
accounts,
next: nextPage
};
}
}

View file

@ -4,19 +4,15 @@ import NiceModal from '@ebay/nice-modal-react';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Heading, List, LoadingIndicator, NoValueLabel, Tab, TabView} from '@tryghost/admin-x-design-system';
import getName from '../utils/get-name';
import getUsername from '../utils/get-username';
import {
type AccountFollowsQueryResult,
type ActivityPubCollectionQueryResult,
useFollowersCountForUser,
useFollowersForUser,
useFollowingCountForUser,
useFollowingForUser,
useLikedCountForUser,
useAccountFollowsForUser,
useAccountForUser,
useLikedForUser,
useOutboxForUser,
useUserDataForUser
useOutboxForUser
} from '../hooks/useActivityPubQueries';
import {MinimalAccount} from '../api/activitypub';
import {handleViewContent} from '../utils/content-handlers';
import APAvatar from './global/APAvatar';
@ -28,13 +24,13 @@ import ViewProfileModal from './modals/ViewProfileModal';
import {type Activity} from '../components/activities/ActivityItem';
interface UseInfiniteScrollTabProps<TData> {
useDataHook: (key: string) => ActivityPubCollectionQueryResult<TData>;
useDataHook: (key: string) => ActivityPubCollectionQueryResult<TData> | AccountFollowsQueryResult;
emptyStateLabel: string;
emptyStateIcon: string;
}
/**
* Hook to abstract away the common logic for infinite scroll in tabs
* Hook to abstract away the common logic for infinite scroll in the tabs
*/
const useInfiniteScrollTab = <TData,>({useDataHook, emptyStateLabel, emptyStateIcon}: UseInfiniteScrollTabProps<TData>) => {
const {
@ -45,7 +41,15 @@ const useInfiniteScrollTab = <TData,>({useDataHook, emptyStateLabel, emptyStateI
isLoading
} = useDataHook('index');
const items = (data?.pages.flatMap(page => page.data) ?? []);
const items = (data?.pages.flatMap((page) => {
if ('data' in page) {
return page.data;
} else if ('accounts' in page) {
return page.accounts as TData[];
}
return [];
}) ?? []);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
@ -172,15 +176,15 @@ const LikesTab: React.FC = () => {
);
};
const handleUserClick = (actor: ActorProperties) => {
const handleAccountClick = (handle: string) => {
NiceModal.show(ViewProfileModal, {
profile: getUsername(actor)
profile: handle
});
};
const FollowingTab: React.FC = () => {
const {items: following, EmptyState, LoadingState} = useInfiniteScrollTab<ActorProperties>({
useDataHook: useFollowingForUser,
const {items: accounts, EmptyState, LoadingState} = useInfiniteScrollTab<MinimalAccount>({
useDataHook: handle => useAccountFollowsForUser(handle, 'following'),
emptyStateLabel: 'You aren\'t following anyone yet.',
emptyStateIcon: 'user-add'
});
@ -190,21 +194,26 @@ const FollowingTab: React.FC = () => {
<EmptyState />
{
<List>
{following.map((item, index) => (
<React.Fragment key={item.id}>
{accounts.map((account, index) => (
<React.Fragment key={account.id}>
<ActivityItem
key={item.id}
onClick={() => handleUserClick(item)}
key={account.id}
onClick={() => handleAccountClick(account.handle)}
>
<APAvatar author={item} />
<APAvatar author={{
icon: {
url: account.avatarUrl
},
name: account.name
}} />
<div>
<div className='text-grey-600'>
<span className='mr-1 font-bold text-black'>{getName(item)}</span>
<div className='text-sm'>{getUsername(item)}</div>
<span className='mr-1 font-bold text-black'>{account.name}</span>
<div className='text-sm'>{account.handle}</div>
</div>
</div>
</ActivityItem>
{index < following.length - 1 && <Separator />}
{index < accounts.length - 1 && <Separator />}
</React.Fragment>
))}
</List>
@ -215,8 +224,8 @@ const FollowingTab: React.FC = () => {
};
const FollowersTab: React.FC = () => {
const {items: followers, EmptyState, LoadingState} = useInfiniteScrollTab<ActorProperties>({
useDataHook: useFollowersForUser,
const {items: accounts, EmptyState, LoadingState} = useInfiniteScrollTab<MinimalAccount>({
useDataHook: handle => useAccountFollowsForUser(handle, 'followers'),
emptyStateLabel: 'Nobody\'s following you yet. Their loss!',
emptyStateIcon: 'user-add'
});
@ -226,21 +235,26 @@ const FollowersTab: React.FC = () => {
<EmptyState />
{
<List>
{followers.map((item, index) => (
<React.Fragment key={item.id}>
{accounts.map((account, index) => (
<React.Fragment key={account.id}>
<ActivityItem
key={item.id}
onClick={() => handleUserClick(item)}
key={account.id}
onClick={() => handleAccountClick(account.handle)}
>
<APAvatar author={item} />
<APAvatar author={{
icon: {
url: account.avatarUrl
},
name: account.name
}} />
<div>
<div className='text-grey-600'>
<span className='mr-1 font-bold text-black'>{item.name || getName(item) || 'Unknown'}</span>
<div className='text-sm'>{getUsername(item)}</div>
<span className='mr-1 font-bold text-black'>{account.name}</span>
<div className='text-sm'>{account.handle}</div>
</div>
</div>
</ActivityItem>
{index < followers.length - 1 && <Separator />}
{index < accounts.length - 1 && <Separator />}
</React.Fragment>
))}
</List>
@ -255,12 +269,7 @@ type ProfileTab = 'posts' | 'likes' | 'following' | 'followers';
interface ProfileProps {}
const Profile: React.FC<ProfileProps> = ({}) => {
const {data: followersCount = 0, isLoading: isLoadingFollowersCount} = useFollowersCountForUser('index');
const {data: followingCount = 0, isLoading: isLoadingFollowingCount} = useFollowingCountForUser('index');
const {data: likedCount = 0, isLoading: isLoadingLikedCount} = useLikedCountForUser('index');
const {data: userProfile, isLoading: isLoadingProfile} = useUserDataForUser('index') as {data: ActorProperties | null, isLoading: boolean};
const isInitialLoading = isLoadingProfile || isLoadingFollowersCount || isLoadingFollowingCount || isLoadingLikedCount;
const {data: account, isLoading: isLoadingAccount} = useAccountForUser('index');
const [selectedTab, setSelectedTab] = useState<ProfileTab>('posts');
@ -282,7 +291,7 @@ const Profile: React.FC<ProfileProps> = ({}) => {
<LikesTab />
</div>
),
counter: likedCount
counter: account?.likedCount || 0
},
{
id: 'following',
@ -292,7 +301,7 @@ const Profile: React.FC<ProfileProps> = ({}) => {
<FollowingTab />
</div>
),
counter: followingCount
counter: account?.followingCount || 0
},
{
id: 'followers',
@ -302,11 +311,16 @@ const Profile: React.FC<ProfileProps> = ({}) => {
<FollowersTab />
</div>
),
counter: followersCount
counter: account?.followerCount || 0
}
].filter(Boolean) as Tab<ProfileTab>[];
const attachments = (userProfile?.attachment || []);
const customFields = Object.keys(account?.customFields || {}).map((key) => {
return {
name: key,
value: account!.customFields[key]
};
}) || [];
const [isExpanded, setisExpanded] = useState(false);
@ -326,45 +340,50 @@ const Profile: React.FC<ProfileProps> = ({}) => {
return (
<>
<MainNavigation page='profile' />
{isInitialLoading ? (
{isLoadingAccount ? (
<div className='flex h-[calc(100vh-8rem)] items-center justify-center'>
<LoadingIndicator />
</div>
) : (
<div className='z-0 mx-auto mt-8 flex w-full max-w-[580px] flex-col items-center pb-16'>
<div className='mx-auto w-full'>
{userProfile?.image && (
{account?.bannerImageUrl && (
<div className='h-[200px] w-full overflow-hidden rounded-lg bg-gradient-to-tr from-grey-200 to-grey-100'>
<img
alt={userProfile?.name}
alt={account?.name}
className='h-full w-full object-cover'
src={userProfile?.image.url}
src={account?.bannerImageUrl}
/>
</div>
)}
<div className={`${userProfile?.image && '-mt-12'} px-4`}>
<div className={`${account?.bannerImageUrl && '-mt-12'} px-4`}>
<div className='flex items-end justify-between'>
<div className='rounded-xl outline outline-4 outline-white'>
<APAvatar
author={userProfile as ActorProperties}
author={account && {
icon: {
url: account?.avatarUrl
},
name: account?.name
}}
size='lg'
/>
</div>
</div>
<Heading className='mt-4' level={3}>{userProfile?.name}</Heading>
<Heading className='mt-4' level={3}>{account?.name}</Heading>
<span className='mt-1 text-[1.5rem] text-grey-800'>
<span>{userProfile && getUsername(userProfile)}</span>
<span>{account?.handle}</span>
</span>
{(userProfile?.summary || attachments.length > 0) && (
{(account?.bio || customFields.length > 0) && (
<div ref={contentRef} className={`ap-profile-content transition-max-height relative text-[1.5rem] duration-300 ease-in-out [&>p]:mb-3 ${isExpanded ? 'max-h-none pb-7' : 'max-h-[160px] overflow-hidden'} relative`}>
<div
dangerouslySetInnerHTML={{__html: userProfile?.summary ?? ''}}
dangerouslySetInnerHTML={{__html: account?.bio ?? ''}}
className='ap-profile-content mt-3 text-[1.5rem] [&>p]:mb-3'
/>
{attachments.map(attachment => (
<span className='mt-3 line-clamp-1 flex flex-col text-[1.5rem]'>
<span className={`text-xs font-semibold`}>{attachment.name}</span>
<span dangerouslySetInnerHTML={{__html: attachment.value}} className='ap-profile-content truncate'/>
{customFields.map(customField => (
<span key={customField.name} className='mt-3 line-clamp-1 flex flex-col text-[1.5rem]'>
<span className={`text-xs font-semibold`}>{customField.name}</span>
<span dangerouslySetInnerHTML={{__html: customField.value}} className='ap-profile-content truncate'/>
</span>
))}
{!isExpanded && isOverflowing && (

View file

@ -9,7 +9,12 @@ import {Icon} from '@tryghost/admin-x-design-system';
type AvatarSize = '2xs' | 'xs' | 'sm' | 'lg' | 'notification';
interface APAvatarProps {
author: ActorProperties | undefined;
author: {
icon: {
url: string;
};
name: string;
} | undefined;
size?: AvatarSize;
}

View file

@ -1,10 +1,25 @@
import {
type AccountFollowsType,
ActivityPubAPI,
ActivityPubCollectionResponse,
ActivityThread,
type GetAccountFollowsResponse,
type Profile,
type SearchResults
} from '../api/activitypub';
import {Activity} from '../components/activities/ActivityItem';
import {ActivityPubAPI, ActivityPubCollectionResponse, ActivityThread, type Profile, type SearchResults} from '../api/activitypub';
import {type UseInfiniteQueryResult, useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {
type UseInfiniteQueryResult,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient
} from '@tanstack/react-query';
let SITE_URL: string;
export type ActivityPubCollectionQueryResult<TData> = UseInfiniteQueryResult<ActivityPubCollectionResponse<TData>>;
export type AccountFollowsQueryResult = UseInfiniteQueryResult<GetAccountFollowsResponse>;
async function getSiteUrl() {
if (!SITE_URL) {
@ -52,17 +67,6 @@ export function useLikedForUser(handle: string) {
});
}
export function useLikedCountForUser(handle: string) {
return useQuery({
queryKey: [`likedCount:${handle}`],
async queryFn() {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getLikedCount();
}
});
}
export function useLikeMutationForUser(handle: string) {
const queryClient = useQueryClient();
return useMutation({
@ -183,17 +187,6 @@ export function useFollowersForUser(handle: string) {
});
}
export function useFollowersCountForUser(handle: string) {
return useQuery({
queryKey: [`followersCount:${handle}`],
async queryFn() {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getFollowersCount();
}
});
}
export function useFollowingForUser(handle: string) {
return useInfiniteQuery({
queryKey: [`following:${handle}`],
@ -208,17 +201,6 @@ export function useFollowingForUser(handle: string) {
});
}
export function useFollowingCountForUser(handle: string) {
return useQuery({
queryKey: [`followingCount:${handle}`],
async queryFn() {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getFollowingCount();
}
});
}
export function useFollow(handle: string, onSuccess: () => void, onError: () => void) {
const queryClient = useQueryClient();
return useMutation({
@ -547,3 +529,28 @@ export function useNoteMutationForUser(handle: string) {
}
});
}
export function useAccountForUser(handle: string) {
return useQuery({
queryKey: [`account:${handle}`],
async queryFn() {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getAccount();
}
});
}
export function useAccountFollowsForUser(handle: string, type: AccountFollowsType) {
return useInfiniteQuery({
queryKey: [`follows:${handle}:${type}`],
async queryFn({pageParam}: {pageParam?: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getAccountFollows(type, pageParam);
},
getNextPageParam(prevPage) {
return prevPage.next;
}
});
}