diff --git a/apps/admin-x-activitypub/src/api/activitypub.test.ts b/apps/admin-x-activitypub/src/api/activitypub.test.ts index 17abd51cb29..63cddb3fbe9 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.test.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.test.ts @@ -454,13 +454,13 @@ describe('ActivityPubAPI', function () { }] }) }, - 'https://activitypub.api/.ghost/activitypub/activities/index?limit=50&includeOwn=false': { + 'https://activitypub.api/.ghost/activitypub/activities/index?limit=50': { response: JSONResponse({ items: [{type: 'Create', object: {type: 'Note'}}], nextCursor: 'next-cursor' }) }, - 'https://activitypub.api/.ghost/activitypub/activities/index?limit=50&includeOwn=false&cursor=next-cursor': { + 'https://activitypub.api/.ghost/activitypub/activities/index?limit=50&cursor=next-cursor': { response: JSONResponse({ items: [{type: 'Announce', object: {type: 'Article'}}], nextCursor: null @@ -515,5 +515,69 @@ describe('ActivityPubAPI', function () { expect(actual).toEqual(expected); }); + + test('It fetches activities with replies', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + 'https://activitypub.api/.ghost/activitypub/activities/index?limit=50&includeReplies=true': { + response: JSONResponse({ + items: [{type: 'Create', object: {type: 'Note'}}], + nextCursor: null + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getAllActivities(false, true); + const expected: Activity[] = [ + {type: 'Create', object: {type: 'Note'}} + ]; + + expect(actual).toEqual(expected); + }); + + test('It fetches filtered activities', async function () { + const fakeFetch = Fetch({ + 'https://auth.api/': { + response: JSONResponse({ + identities: [{ + token: 'fake-token' + }] + }) + }, + [`https://activitypub.api/.ghost/activitypub/activities/index?limit=50&filter=%7B%22type%22%3A%5B%22Create%3ANote%22%5D%7D`]: { + response: JSONResponse({ + items: [{type: 'Create', object: {type: 'Note'}}], + nextCursor: null + }) + } + }); + + const api = new ActivityPubAPI( + new URL('https://activitypub.api'), + new URL('https://auth.api'), + 'index', + fakeFetch + ); + + const actual = await api.getAllActivities(false, false, {type: ['Create:Note']}); + const expected: Activity[] = [ + {type: 'Create', object: {type: 'Note'}} + ]; + + expect(actual).toEqual(expected); + }); }); }); diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts index e5427ab0ee4..6d97bdbbd23 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.ts @@ -147,7 +147,11 @@ export class ActivityPubAPI { return new URL(`.ghost/activitypub/activities/${this.handle}`, this.apiUrl); } - async getAllActivities(includeOwn: boolean = false): Promise { + async getAllActivities( + includeOwn: boolean = false, + includeReplies: boolean = false, + filter: {type?: string[]} | null = null + ): Promise { const LIMIT = 50; const fetchActivities = async (url: URL): Promise => { @@ -174,7 +178,15 @@ export class ActivityPubAPI { nextUrl.searchParams.set('cursor', json.nextCursor); nextUrl.searchParams.set('limit', LIMIT.toString()); - nextUrl.searchParams.set('includeOwn', includeOwn.toString()); + if (includeOwn) { + nextUrl.searchParams.set('includeOwn', includeOwn.toString()); + } + if (includeReplies) { + nextUrl.searchParams.set('includeReplies', includeReplies.toString()); + } + if (filter) { + nextUrl.searchParams.set('filter', JSON.stringify(filter)); + } const nextItems = await fetchActivities(nextUrl); @@ -187,7 +199,15 @@ export class ActivityPubAPI { // Make a copy of the activities API URL and set the limit const url = new URL(this.activitiesApiUrl); url.searchParams.set('limit', LIMIT.toString()); - url.searchParams.set('includeOwn', includeOwn.toString()); + if (includeOwn) { + url.searchParams.set('includeOwn', includeOwn.toString()); + } + if (includeReplies) { + url.searchParams.set('includeReplies', includeReplies.toString()); + } + if (filter) { + url.searchParams.set('filter', JSON.stringify(filter)); + } // Fetch the activities return fetchActivities(url); diff --git a/apps/admin-x-activitypub/src/components/Activities.tsx b/apps/admin-x-activitypub/src/components/Activities.tsx index f7e7966e65b..4febdbee91e 100644 --- a/apps/admin-x-activitypub/src/components/Activities.tsx +++ b/apps/admin-x-activitypub/src/components/Activities.tsx @@ -1,7 +1,6 @@ import NiceModal from '@ebay/nice-modal-react'; import React from 'react'; import {Button, NoValueLabel} from '@tryghost/admin-x-design-system'; -import {ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import APAvatar, {AvatarBadge} from './global/APAvatar'; import ActivityItem, {type Activity} from './activities/ActivityItem'; @@ -21,13 +20,11 @@ enum ACTVITY_TYPE { FOLLOW = 'Follow' } -const getActivityDescription = (activity: Activity, activityObjectsMap: Map): string => { +const getActivityDescription = (activity: Activity): string => { switch (activity.type) { case ACTVITY_TYPE.CREATE: - const object = activityObjectsMap.get(activity.object?.inReplyTo || ''); - - if (object?.name) { - return `Commented on your article "${object.name}"`; + if (activity.object?.inReplyTo && typeof activity.object?.inReplyTo !== 'string') { + return `Commented on your article "${activity.object.inReplyTo.name}"`; } return ''; @@ -87,67 +84,17 @@ const getActivityBadge = (activity: Activity): AvatarBadge => { const Activities: React.FC = ({}) => { const user = 'index'; - - let {data: activities = []} = useAllActivitiesForUser({handle: 'index', includeOwn: true}); const siteUrl = useSiteUrl(); - // Create a map of activity objects from activities in the inbox and outbox. - // This allows us to quickly look up an object associated with an activity - // We could just make a http request to get the object, but this is more - // efficient seeming though we already have the data in the inbox and outbox - const activityObjectsMap = new Map(); - - activities.forEach((activity) => { - if (activity.object) { - activityObjectsMap.set(activity.object.id, activity.object); - } - }); - - // Filter the activities to show - activities = activities.filter((activity) => { - if (activity.type === ACTVITY_TYPE.CREATE) { - // Only show "Create" activities that are replies to a post created - // by the user - - const replyToObject = activityObjectsMap.get(activity.object?.inReplyTo || ''); - - // If the reply object is not found, or it doesn't have a URL or - // name, do not show the activity - if (!replyToObject || !replyToObject.url || !replyToObject.name) { - return false; - } - - // Verify that the reply is to a post created by the user by - // checking that the hostname associated with the reply object - // is the same as the hostname of the site. This is not a bullet - // proof check, but it's a good enough for now - const hostname = new URL(siteUrl).hostname; - const replyToObjectHostname = new URL(replyToObject.url).hostname; - - return hostname === replyToObjectHostname; + const {data: activities = []} = useAllActivitiesForUser({ + handle: user, + includeOwn: true, + includeReplies: true, + filter: { + type: ['Follow', 'Like', `Create:Note:isReplyToOwn,${new URL(siteUrl).hostname}`] } - - return [ACTVITY_TYPE.FOLLOW, ACTVITY_TYPE.LIKE].includes(activity.type); }); - // Create a map of activity comments, grouping them by the parent activity - // This allows us to quickly look up all comments for a given activity - const commentsMap = new Map(); - - for (const activity of activities) { - if (activity.type === ACTVITY_TYPE.CREATE && activity.object?.inReplyTo) { - const comments = commentsMap.get(activity.object.inReplyTo) ?? []; - - comments.push(activity); - - commentsMap.set(activity.object.inReplyTo, comments.reverse()); - } - } - - const getCommentsForObject = (id: string) => { - return commentsMap.get(id) ?? []; - }; - // Retrieve followers for the user const {data: followers = []} = useFollowersForUser(user); @@ -177,8 +124,7 @@ const Activities: React.FC = ({}) => { NiceModal.show(ArticleModal, { object: activity.object, actor: activity.actor, - comments: getCommentsForObject(activity.object.id), - allComments: commentsMap + comments: activity.object.replies }); } : undefined } @@ -189,7 +135,7 @@ const Activities: React.FC = ({}) => { {activity.actor.name} {getUsername(activity.actor)} -
{getActivityDescription(activity, activityObjectsMap)}
+
{getActivityDescription(activity)}
{getExtendedDescription(activity)} {isFollower(activity.actor.id) === false && ( diff --git a/apps/admin-x-activitypub/src/components/Inbox.tsx b/apps/admin-x-activitypub/src/components/Inbox.tsx index dbe4fa802d8..feff3b3b680 100644 --- a/apps/admin-x-activitypub/src/components/Inbox.tsx +++ b/apps/admin-x-activitypub/src/components/Inbox.tsx @@ -17,37 +17,18 @@ const Inbox: React.FC = ({}) => { const [layout, setLayout] = useState('inbox'); // Retrieve all activities for the user - let {data: activities = []} = useAllActivitiesForUser({handle: 'index'}); - - activities = activities.filter((activity: Activity) => { - const isCreate = activity.type === 'Create' && ['Article', 'Note'].includes(activity.object.type); - const isAnnounce = activity.type === 'Announce' && activity.object.type === 'Note'; - - return isCreate || isAnnounce; - }); - - // Create a map of activity comments, grouping them by the parent activity - // This allows us to quickly look up all comments for a given activity - const commentsMap = new Map(); - - for (const activity of activities) { - if (activity.type === 'Create' && activity.object.inReplyTo) { - const comments = commentsMap.get(activity.object.inReplyTo) ?? []; - - comments.push(activity); - - commentsMap.set(activity.object.inReplyTo, comments.reverse()); + const {data: activities = []} = useAllActivitiesForUser({ + handle: 'index', + includeReplies: true, + filter: { + type: ['Create:Article', 'Create:Note', 'Announce:Note'] } - } - - const getCommentsForObject = (id: string) => { - return commentsMap.get(id) ?? []; - }; + }); const handleViewContent = (object: ObjectProperties, actor: ActorProperties, comments: Activity[]) => { setArticleContent(object); setArticleActor(actor); - NiceModal.show(ArticleModal, {object, actor, comments, allComments: commentsMap}); + NiceModal.show(ArticleModal, {object, actor, comments}); }; function getContentAuthor(activity: Activity) { @@ -92,12 +73,12 @@ const Inbox: React.FC = ({}) => { onClick={() => handleViewContent( activity.object, getContentAuthor(activity), - getCommentsForObject(activity.object.id) + activity.object.replies )} > ; } const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => { @@ -73,7 +72,7 @@ const FeedItemDivider: React.FC = () => (
); -const ArticleModal: React.FC = ({object, actor, comments, allComments}) => { +const ArticleModal: React.FC = ({object, actor, comments}) => { const MODAL_SIZE_SM = 640; const MODAL_SIZE_LG = 1024; @@ -98,8 +97,7 @@ const ArticleModal: React.FC = ({object, actor, comments, all modal.show({ object: previousObject, actor: previousActor, - comments: previousComments, - allComments: allComments + comments: previousComments }); }; const navigateForward = (nextObject: ObjectProperties, nextActor: ActorProperties, nextComments: Activity[]) => { @@ -109,8 +107,7 @@ const ArticleModal: React.FC = ({object, actor, comments, all modal.show({ object: nextObject, actor: nextActor, - comments: nextComments, - allComments: allComments + comments: nextComments }); }; const toggleModalSize = () => { @@ -161,7 +158,7 @@ const ArticleModal: React.FC = ({object, actor, comments, all */} {comments.map((comment, index) => { const showDivider = index !== comments.length - 1; - const nestedComments = allComments.get(comment.object.id) ?? []; + const nestedComments = comment.object.replies ?? []; const hasNestedComments = nestedComments.length > 0; return ( @@ -179,7 +176,7 @@ const ArticleModal: React.FC = ({object, actor, comments, all /> {hasNestedComments && } {nestedComments.map((nestedComment, nestedCommentIndex) => { - const nestedNestedComments = allComments.get(nestedComment.object.id) ?? []; + const nestedNestedComments = nestedComment.object.replies ?? []; return (