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

Updated following/followers in activitypub profile preview to be dynamic (#21170)

refs
[AP-442](https://linear.app/tryghost/issue/AP-442/add-dynamic-following-followers-to-search-result-profile),
[TryGhost/ActivityPub#53](https://github.com/TryGhost/ActivityPub/pull/53)

Updated following/followers in activitypub profile preview to be dynamic
This commit is contained in:
Michael Barrett 2024-10-02 15:41:25 +01:00 committed by GitHub
parent bce5d9d588
commit d3d2ea44e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 756 additions and 26 deletions

View file

@ -623,4 +623,472 @@ describe('ActivityPubAPI', function () {
expect(actual).toEqual(expected);
});
});
describe('getFollowersForProfile', function () {
test('It returns an array of followers 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}/followers`]: {
response: JSONResponse({
followers: [
{
actor: {
id: 'https://example.com/users/bar'
},
isFollowing: false
},
{
actor: {
id: 'https://example.com/users/baz'
},
isFollowing: false
}
],
next: null
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowersForProfile(handle);
expect(actual.followers).toEqual([
{
actor: {
id: 'https://example.com/users/bar'
},
isFollowing: false
},
{
actor: {
id: 'https://example.com/users/baz'
},
isFollowing: false
}
]);
});
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}/followers`]: {
response: JSONResponse({
followers: [],
next: 'abc123'
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowersForProfile(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}/followers?next=${next}`]: {
response: JSONResponse({
followers: [
{
actor: {
id: 'https://example.com/users/qux'
},
isFollowing: false
}
],
next: null
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowersForProfile(handle, next);
const expected = {
followers: [
{
actor: {
id: 'https://example.com/users/qux'
},
isFollowing: false
}
],
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}/followers`]: {
response: JSONResponse(null)
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowersForProfile(handle);
const expected = {
followers: [],
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}/followers`]: {
response: JSONResponse({})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowersForProfile(handle);
const expected = {
followers: [],
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}/followers`]: {
response: JSONResponse({
followers: {}
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowersForProfile(handle);
expect(actual.followers).toEqual([]);
});
});
describe('getFollowingForProfile', function () {
test('It returns a following arrayfor 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}/following`]: {
response: JSONResponse({
following: [
{
actor: {
id: 'https://example.com/users/bar'
},
isFollowing: false
},
{
actor: {
id: 'https://example.com/users/baz'
},
isFollowing: false
}
],
next: null
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowingForProfile(handle);
expect(actual.following).toEqual([
{
actor: {
id: 'https://example.com/users/bar'
},
isFollowing: false
},
{
actor: {
id: 'https://example.com/users/baz'
},
isFollowing: false
}
]);
});
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}/following`]: {
response: JSONResponse({
following: [],
next: 'abc123'
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowingForProfile(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}/following?next=${next}`]: {
response: JSONResponse({
following: [
{
actor: {
id: 'https://example.com/users/qux'
},
isFollowing: false
}
],
next: null
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowingForProfile(handle, next);
const expected = {
following: [
{
actor: {
id: 'https://example.com/users/qux'
},
isFollowing: false
}
],
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}/following`]: {
response: JSONResponse(null)
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowingForProfile(handle);
const expected = {
following: [],
next: null
};
expect(actual).toEqual(expected);
});
test('It returns a default return value if following 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}/following`]: {
response: JSONResponse({})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowingForProfile(handle);
const expected = {
following: [],
next: null
};
expect(actual).toEqual(expected);
});
test('It returns an empty following array if following 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}/following`]: {
response: JSONResponse({
following: {}
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowingForProfile(handle);
expect(actual.following).toEqual([]);
});
});
});

View file

@ -15,6 +15,22 @@ export interface SearchResults {
profiles: ProfileSearchResult[];
}
export interface GetFollowersForProfileResponse {
followers: {
actor: Actor;
isFollowing: boolean;
}[];
next: string | null;
}
export interface GetFollowingForProfileResponse {
following: {
actor: Actor;
isFollowing: boolean;
}[];
next: string | null;
}
export class ActivityPubAPI {
constructor(
private readonly apiUrl: URL,
@ -125,6 +141,68 @@ export class ActivityPubAPI {
return 0;
}
async getFollowersForProfile(handle: string, next?: string): Promise<GetFollowersForProfileResponse> {
const url = new URL(`.ghost/activitypub/profile/${handle}/followers`, this.apiUrl);
if (next) {
url.searchParams.set('next', next);
}
const json = await this.fetchJSON(url);
if (json === null) {
return {
followers: [],
next: null
};
}
if (!('followers' in json)) {
return {
followers: [],
next: null
};
}
const followers = Array.isArray(json.followers) ? json.followers : [];
const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null;
return {
followers,
next: nextPage
};
}
async getFollowingForProfile(handle: string, next?: string): Promise<GetFollowingForProfileResponse> {
const url = new URL(`.ghost/activitypub/profile/${handle}/following`, this.apiUrl);
if (next) {
url.searchParams.set('next', next);
}
const json = await this.fetchJSON(url);
if (json === null) {
return {
following: [],
next: null
};
}
if (!('following' in json)) {
return {
following: [],
next: null
};
}
const following = Array.isArray(json.following) ? json.following : [];
const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null;
return {
following,
next: nextPage
};
}
async follow(username: string): Promise<void> {
const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl);
await this.fetchJSON(url, 'POST');

View file

@ -18,6 +18,7 @@ interface SearchResultItem {
actor: ActorProperties;
handle: string;
followerCount: number;
followingCount: number;
isFollowing: boolean;
posts: Activity[];
}
@ -84,10 +85,10 @@ const Search: React.FC<SearchProps> = ({}) => {
icon: {
url: 'https://anujahooja.com/assets/images/image12.jpg?v=601ebe30'
}
} as ActorProperties,
handle: '@quillmatiq@mastodon.social',
followerCount: 436,
followingCount: 634,
isFollowing: false,
posts: []
},
@ -105,6 +106,7 @@ const Search: React.FC<SearchProps> = ({}) => {
} as ActorProperties,
handle: '@miaq@flipboard.social',
followerCount: 533,
followingCount: 335,
isFollowing: false,
posts: []
},
@ -122,6 +124,7 @@ const Search: React.FC<SearchProps> = ({}) => {
} as ActorProperties,
handle: '@mallory@techpolicy.social',
followerCount: 1100,
followingCount: 11,
isFollowing: false,
posts: []
}

View file

@ -1,17 +1,146 @@
import React from 'react';
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 {Button, Heading, Modal} from '@tryghost/admin-x-design-system';
import {Button, Heading, 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} from '../../hooks/useActivityPubQueries';
import APAvatar from '../global/APAvatar';
import ActivityItem from '../activities/ActivityItem';
import FeedItem from '../feed/FeedItem';
import FollowButton from '../global/FollowButton';
import getUsername from '../../utils/get-username';
const noop = () => {};
type QueryPageData = GetFollowersForProfileResponse | GetFollowingForProfileResponse;
type QueryFn = (handle: string) => UseInfiniteQueryResult<QueryPageData, unknown>;
type ActorListProps = {
handle: string,
noResultsMessage: string,
queryFn: QueryFn,
resolveDataFn: (data: QueryPageData) => GetFollowersForProfileResponse['followers'] | GetFollowingForProfileResponse['following'];
};
const ActorList: React.FC<ActorListProps> = ({
handle,
noResultsMessage,
queryFn,
resolveDataFn
}) => {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading
} = queryFn(handle);
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);
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>
{
actorData.length === 0 && !isLoading ? (
<NoValueLabel icon='user-add'>
{noResultsMessage}
</NoValueLabel>
) : (
<List>
{actorData.map(({actor, isFollowing}) => {
return (
<ActivityItem key={actor.id} url={actor.url}>
<APAvatar author={actor} />
<div>
<div className='text-grey-600'>
<span className='mr-1 font-bold text-black'>{actor.name || actor.preferredUsername || 'Unknown'}</span>
<div className='text-sm'>{getUsername(actor)}</div>
</div>
</div>
<FollowButton
className='ml-auto'
following={isFollowing}
handle={getUsername(actor)}
type='link'
/>
</ActivityItem>
);
})}
</List>
)
}
<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>
);
};
const FollowersTab: React.FC<{handle: string}> = ({handle}) => {
return (
<ActorList
handle={handle}
noResultsMessage={`${handle} has no followers yet`}
queryFn={useFollowersForProfile}
resolveDataFn={page => ('followers' in page ? page.followers : [])}
/>
);
};
const FollowingTab: React.FC<{handle: string}> = ({handle}) => {
return (
<ActorList
handle={handle}
noResultsMessage={`${handle} is not following anyone yet`}
queryFn={useFollowingForProfile}
resolveDataFn={page => ('following' in page ? page.following : [])}
/>
);
};
interface ProfileSearchResultModalProps {
profile: {
actor: ActorProperties;
handle: string;
followerCount: number;
followingCount: number;
isFollowing: boolean;
posts: Activity[];
};
@ -19,7 +148,7 @@ interface ProfileSearchResultModalProps {
onUnfollow: () => void;
}
const noop = () => {};
type ProfileTab = 'posts' | 'following' | 'followers';
const ProfileSearchResultModal: React.FC<ProfileSearchResultModalProps> = ({
profile,
@ -27,9 +156,47 @@ const ProfileSearchResultModal: React.FC<ProfileSearchResultModalProps> = ({
onUnfollow = noop
}) => {
const modal = useModal();
const attachments = (profile.actor.attachment || [])
.filter(attachment => attachment.type === 'PropertyValue');
const posts = profile.posts; // @TODO: Do any filtering / manipulation here
const [selectedTab, setSelectedTab] = useState<ProfileTab>('posts');
const attachments = (profile.actor.attachment || []);
const posts = (profile.posts || []).filter(post => post.type !== 'Announce');
const tabs = [
{
id: 'posts',
title: 'Posts',
contents: (
<div>
{posts.map(post => (
<FeedItem
actor={profile.actor}
comments={post.object.replies}
layout='feed'
object={post.object}
type={post.type}
onCommentClick={() => {}}
/>
))}
</div>
)
},
{
id: 'following',
title: 'Following',
contents: (
<FollowingTab handle={profile.handle} />
),
counter: profile.followingCount
},
{
id: 'followers',
title: 'Followers',
contents: (
<FollowersTab handle={profile.handle} />
),
counter: profile.followerCount
}
].filter(Boolean) as Tab<ProfileTab>[];
return (
<Modal
@ -84,25 +251,12 @@ const ProfileSearchResultModal: React.FC<ProfileSearchResultModalProps> = ({
<span dangerouslySetInnerHTML={{__html: attachment.value}} className='ap-profile-content truncate'/>
</span>
))}
<Heading className='mt-8' level={5}>Posts</Heading>
{posts.map((post) => {
if (post.type === 'Announce') {
return null;
} else {
return (
<FeedItem
actor={profile.actor}
comments={post.object.replies}
layout='feed'
object={post.object}
type={post.type}
onCommentClick={() => {}}
/>
);
}
})}
<TabView<ProfileTab>
containerClassName='mt-6'
selectedTab={selectedTab}
tabs={tabs}
onTabChange={setSelectedTab}
/>
</div>
</div>
</div>

View file

@ -279,3 +279,30 @@ export function useFollow(handle: string, onSuccess: () => void, onError: () =>
});
}
export function useFollowersForProfile(handle: string) {
const siteUrl = useSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return useInfiniteQuery({
queryKey: [`followers:${handle}`],
async queryFn({pageParam}: {pageParam?: string}) {
return api.getFollowersForProfile(handle, pageParam);
},
getNextPageParam(prevPage) {
return prevPage.next;
}
});
}
export function useFollowingForProfile(handle: string) {
const siteUrl = useSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return useInfiniteQuery({
queryKey: [`following:${handle}`],
async queryFn({pageParam}: {pageParam?: string}) {
return api.getFollowingForProfile(handle, pageParam);
},
getNextPageParam(prevPage) {
return prevPage.next;
}
});
}