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:
parent
cfda52ead2
commit
59a3a9514b
@ -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];
|
||||||
|
@ -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 () {
|
||||||
|
@ -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 [];
|
||||||
}
|
}
|
||||||
|
@ -2,51 +2,112 @@ 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'>
|
||||||
<div className='mt-8 flex w-full max-w-[560px] flex-col'>
|
{activities.length === 0 && (
|
||||||
<ActivityItem>
|
<div className='mt-8 font-bold'>This is an empty state when there are no activities</div>
|
||||||
<APAvatar />
|
)}
|
||||||
<div>
|
{activities.length > 0 && (
|
||||||
<div className='text-grey-600'><span className='font-bold text-black'>Lydia Mango</span> @username@domain.com</div>
|
<div className='mt-8 flex w-full max-w-[560px] flex-col'>
|
||||||
<div className='text-sm'>Followed you</div>
|
{activities?.map(activity => (
|
||||||
</div>
|
<ActivityItem key={activity.id} url={getActivityUrl(activity)}>
|
||||||
</ActivityItem>
|
<APAvatar author={activity.actor} />
|
||||||
|
<div>
|
||||||
|
<div className='text-grey-600'>
|
||||||
|
<span className='mr-1 font-bold text-black'>{activity.actor.name}</span>
|
||||||
|
{getActorUsername(activity.actor)}
|
||||||
|
</div>
|
||||||
|
<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>
|
</ActivityItem>
|
||||||
<div className='text-sm'>Followed you</div>
|
))}
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Activities;
|
export default Activities;
|
||||||
|
@ -86,4 +86,4 @@ const Inbox: React.FC<InboxProps> = ({}) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Inbox;
|
export default Inbox;
|
||||||
|
@ -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;
|
||||||
|
Loading…
Reference in New Issue
Block a user