Added data retrieval for activitypub activities (#20830)

refs
[AP-283](https://linear.app/tryghost/issue/AP-283/handle-incoming-likes)

Added data retrieval for activitypub activities in the acvitiies tab of
the ActivityPub demo
This commit is contained in:
Michael Barrett 2024-08-28 09:39:01 +01:00 committed by GitHub
parent cfda52ead2
commit 59a3a9514b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 132 additions and 80 deletions

View File

@ -24,6 +24,23 @@ export function useBrowseInboxForUser(handle: string) {
}); });
} }
export function useFollowersForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`followers:${handle}`],
async queryFn() {
return api.getFollowers();
}
});
}
const MainContent = () => { const MainContent = () => {
const {route} = useRouting(); const {route} = useRouting();
const mainRoute = route.split('/')[0]; const mainRoute = route.split('/')[0];

View File

@ -334,7 +334,7 @@ describe('ActivityPubAPI', function () {
}, },
response: JSONResponse({ response: JSONResponse({
type: 'Collection', type: 'Collection',
items: [] orderedItems: []
}) })
} }
}); });
@ -360,7 +360,7 @@ describe('ActivityPubAPI', function () {
'https://activitypub.api/.ghost/activitypub/followers/index': { 'https://activitypub.api/.ghost/activitypub/followers/index': {
response: JSONResponse({ response: JSONResponse({
type: 'Collection', type: 'Collection',
items: [] orderedItems: []
}) })
} }
}); });
@ -390,7 +390,7 @@ describe('ActivityPubAPI', function () {
response: response:
JSONResponse({ JSONResponse({
type: 'Collection', type: 'Collection',
items: [{ orderedItems: [{
type: 'Person' type: 'Person'
}] }]
}) })
@ -413,43 +413,6 @@ describe('ActivityPubAPI', function () {
expect(actual).toEqual(expected); expect(actual).toEqual(expected);
}); });
test('Returns an array when the items key is a single object', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
'https://activitypub.api/.ghost/activitypub/followers/index': {
response:
JSONResponse({
type: 'Collection',
items: {
type: 'Person'
}
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFollowers();
const expected: Activity[] = [
{
type: 'Person'
}
];
expect(actual).toEqual(expected);
});
}); });
describe('follow', function () { describe('follow', function () {

View File

@ -85,8 +85,8 @@ export class ActivityPubAPI {
if (json === null) { if (json === null) {
return []; return [];
} }
if ('items' in json) { if ('orderedItems' in json) {
return Array.isArray(json.items) ? json.items : [json.items]; return json.orderedItems as Activity[];
} }
return []; return [];
} }

View File

@ -2,48 +2,109 @@ import APAvatar from './global/APAvatar';
import ActivityItem from './activities/ActivityItem'; import ActivityItem from './activities/ActivityItem';
import MainNavigation from './navigation/MainNavigation'; import MainNavigation from './navigation/MainNavigation';
import React from 'react'; import React from 'react';
import {Button} from '@tryghost/admin-x-design-system';
import {useBrowseInboxForUser, useFollowersForUser} from '../MainContent';
interface ActivitiesProps {} interface ActivitiesProps {}
// eslint-disable-next-line no-shadow
enum ACTVITY_TYPE {
LIKE = 'Like',
FOLLOW = 'Follow'
}
type Actor = {
id: string
name: string
preferredUsername: string
url: string
}
type ActivityObject = {
name: string
url: string
}
type Activity = {
id: string
type: ACTVITY_TYPE
object?: ActivityObject
actor: Actor
}
const getActorUsername = (actor: Actor): string => {
const url = new URL(actor.url);
const domain = url.hostname;
return `@${actor.preferredUsername}@${domain}`;
};
const getActivityDescription = (activity: Activity): string => {
switch (activity.type) {
case ACTVITY_TYPE.FOLLOW:
return 'Followed you';
case ACTVITY_TYPE.LIKE:
if (activity.object) {
return `Liked your article "${activity.object.name}"`;
}
}
return '';
};
const getActivityUrl = (activity: Activity): string | null => {
if (activity.object) {
return activity.object.url;
}
return null;
};
const isFollower = (id: string, followerIds: string[]): boolean => {
return followerIds.includes(id);
};
const Activities: React.FC<ActivitiesProps> = ({}) => { const Activities: React.FC<ActivitiesProps> = ({}) => {
// const fakeAuthor = const user = 'index';
const {data: activityData} = useBrowseInboxForUser(user);
const activities = (activityData || [])
.filter((activity) => {
return [ACTVITY_TYPE.FOLLOW, ACTVITY_TYPE.LIKE].includes(activity.type);
})
.reverse(); // Endpoint currently returns items oldest-newest
const {data: followerData} = useFollowersForUser(user);
const followers = followerData || [];
return ( return (
<> <>
<MainNavigation title='Activities' /> <MainNavigation title='Activities' />
<div className='z-0 flex w-full flex-col items-center'> <div className='z-0 flex w-full flex-col items-center'>
{activities.length === 0 && (
<div className='mt-8 font-bold'>This is an empty state when there are no activities</div>
)}
{activities.length > 0 && (
<div className='mt-8 flex w-full max-w-[560px] flex-col'> <div className='mt-8 flex w-full max-w-[560px] flex-col'>
<ActivityItem> {activities?.map(activity => (
<APAvatar /> <ActivityItem key={activity.id} url={getActivityUrl(activity)}>
<APAvatar author={activity.actor} />
<div> <div>
<div className='text-grey-600'><span className='font-bold text-black'>Lydia Mango</span> @username@domain.com</div> <div className='text-grey-600'>
<div className='text-sm'>Followed you</div> <span className='mr-1 font-bold text-black'>{activity.actor.name}</span>
{getActorUsername(activity.actor)}
</div> </div>
</ActivityItem> <div className='text-sm'>{getActivityDescription(activity)}</div>
</div>
{isFollower(activity.actor.id, followers) === false && (
<Button className='ml-auto' label='Follow' link onClick={(e) => {
e?.preventDefault();
<ActivityItem> alert('Implement me!');
<APAvatar /> }} />
<div> )}
<div className='text-grey-600'><span className='font-bold text-black'>Tiana Passaquindici Arcand</span> @username@domain.com</div>
<div className='text-sm'>Followed you</div>
</div>
</ActivityItem>
<ActivityItem>
<APAvatar />
<div>
<div className='text-grey-600'><span className='font-bold text-black'>Gretchen Press</span> @username@domain.com</div>
<div className='text-sm'>Followed you</div>
</div>
</ActivityItem>
<ActivityItem>
<APAvatar />
<div>
<div className='text-grey-600'><span className='font-bold text-black'>Leo Lubin</span> @username@domain.com</div>
<div className='text-sm'>Followed you</div>
</div>
</ActivityItem> </ActivityItem>
))}
</div> </div>
)}
</div> </div>
</> </>
); );

View File

@ -9,13 +9,14 @@ export type Activity = {
interface ActivityItemProps { interface ActivityItemProps {
children?: ReactNode; children?: ReactNode;
url?: string | null;
} }
const ActivityItem: React.FC<ActivityItemProps> = ({children}) => { const ActivityItem: React.FC<ActivityItemProps> = ({children, url = null}) => {
const childrenArray = React.Children.toArray(children); const childrenArray = React.Children.toArray(children);
return ( const Item = (
<div className='flex w-full max-w-[560px] flex-col'> <div className='flex w-full max-w-[560px] flex-col hover:bg-grey-75'>
<div className='flex w-full items-center gap-3 border-b border-grey-100 py-4'> <div className='flex w-full items-center gap-3 border-b border-grey-100 py-4'>
{childrenArray[0]} {childrenArray[0]}
{childrenArray[1]} {childrenArray[1]}
@ -23,6 +24,16 @@ const ActivityItem: React.FC<ActivityItemProps> = ({children}) => {
</div> </div>
</div> </div>
); );
if (url) {
return (
<a href={url} rel='noreferrer' target='_blank'>
{Item}
</a>
);
}
return Item;
}; };
export default ActivityItem; export default ActivityItem;