diff --git a/apps/admin-x-activitypub/src/MainContent.tsx b/apps/admin-x-activitypub/src/MainContent.tsx index 807ec785fb..ba185bea33 100644 --- a/apps/admin-x-activitypub/src/MainContent.tsx +++ b/apps/admin-x-activitypub/src/MainContent.tsx @@ -24,6 +24,23 @@ export function useBrowseInboxForUser(handle: string) { }); } +export function useBrowseOutboxForUser(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: [`outbox:${handle}`], + async queryFn() { + return api.getOutbox(); + } + }); +} + export function useFollowersForUser(handle: string) { const site = useBrowseSite(); const siteData = site.data?.site; diff --git a/apps/admin-x-activitypub/src/api/activitypub.ts b/apps/admin-x-activitypub/src/api/activitypub.ts index 8b8d2fc804..7065368999 100644 --- a/apps/admin-x-activitypub/src/api/activitypub.ts +++ b/apps/admin-x-activitypub/src/api/activitypub.ts @@ -53,6 +53,24 @@ export class ActivityPubAPI { return []; } + get outboxApiUrl() { + return new URL(`.ghost/activitypub/outbox/${this.handle}`, this.apiUrl); + } + + async getOutbox(): Promise { + const json = await this.fetchJSON(this.outboxApiUrl); + if (json === null) { + return []; + } + if ('orderedItems' in json) { + return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems]; + } + if ('items' in json) { + return Array.isArray(json.items) ? json.items : [json.items]; + } + return []; + } + get followingApiUrl() { return new URL(`.ghost/activitypub/following/${this.handle}`, this.apiUrl); } diff --git a/apps/admin-x-activitypub/src/components/Activities.tsx b/apps/admin-x-activitypub/src/components/Activities.tsx index 9f784beed2..403a3309da 100644 --- a/apps/admin-x-activitypub/src/components/Activities.tsx +++ b/apps/admin-x-activitypub/src/components/Activities.tsx @@ -1,14 +1,18 @@ +import React from 'react'; + import APAvatar, {AvatarBadge} from './global/APAvatar'; import ActivityItem from './activities/ActivityItem'; import MainNavigation from './navigation/MainNavigation'; -import React from 'react'; import {Button} from '@tryghost/admin-x-design-system'; -import {useBrowseInboxForUser, useFollowersForUser} from '../MainContent'; + +import getUsername from '../utils/get-username'; +import {useBrowseInboxForUser, useBrowseOutboxForUser, useFollowersForUser} from '../MainContent'; interface ActivitiesProps {} // eslint-disable-next-line no-shadow enum ACTVITY_TYPE { + CREATE = 'Create', LIKE = 'Like', FOLLOW = 'Follow' } @@ -23,6 +27,8 @@ type Actor = { type ActivityObject = { name: string url: string + inReplyTo: string | null + content: string } type Activity = { @@ -32,15 +38,16 @@ type Activity = { 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 => { +const getActivityDescription = (activity: Activity, activityObjectsMap: Map): 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}"`; + } + + return ''; case ACTVITY_TYPE.FOLLOW: return 'Followed you'; case ACTVITY_TYPE.LIKE: @@ -52,6 +59,20 @@ const getActivityDescription = (activity: Activity): string => { return ''; }; +const getExtendedDescription = (activity: Activity): JSX.Element | null => { + // If the activity is a reply + if (Boolean(activity.type === ACTVITY_TYPE.CREATE && activity.object?.inReplyTo)) { + return ( +
+ ); + } + + return null; +}; + const getActivityUrl = (activity: Activity): string | null => { if (activity.object) { return activity.object.url; @@ -70,29 +91,80 @@ const getActorUrl = (activity: Activity): string | null => { const getActivityBadge = (activity: Activity): AvatarBadge => { switch (activity.type) { + case ACTVITY_TYPE.CREATE: + return 'user-fill'; // TODO: Change this case ACTVITY_TYPE.FOLLOW: return 'user-fill'; case ACTVITY_TYPE.LIKE: if (activity.object) { return 'heart-fill'; } - } -}; - -const isFollower = (id: string, followerIds: string[]): boolean => { - return followerIds.includes(id); + } }; const Activities: React.FC = ({}) => { 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 || []; + + // Retrieve activities from the inbox AND the outbox + // Why the need for the outbox? The outbox contains activities that the user + // has performed, and we sometimes need information about the object + // associated with the activity (i.e when displaying the name of an article + // that a reply was made to) + const {data: inboxActivities = []} = useBrowseInboxForUser(user); + const {data: outboxActivities = []} = useBrowseOutboxForUser(user); + + // 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(); + + outboxActivities.forEach((activity) => { + if (activity.object) { + activityObjectsMap.set(activity.object.id, activity.object); + } + }); + inboxActivities.forEach((activity) => { + if (activity.object) { + activityObjectsMap.set(activity.object.id, activity.object); + } + }); + + // Filter the activities to show + const activities = inboxActivities.filter((activity) => { + // Only show "Create" activities that are replies to a post created + // by the user + if (activity.type === ACTVITY_TYPE.CREATE) { + 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(window.location.href).hostname; + const replyToObjectHostname = new URL(replyToObject.url).hostname; + + return hostname === replyToObjectHostname; + } + + return [ACTVITY_TYPE.FOLLOW, ACTVITY_TYPE.LIKE].includes(activity.type); + }) + // API endpoint currently returns items oldest-newest, so reverse them + // to show the most recent activities first + .reverse(); + + // Retrieve followers for the user + const {data: followers = []} = useFollowersForUser(user); + + const isFollower = (id: string): boolean => { + return followers.includes(id); + }; return ( <> @@ -109,11 +181,12 @@ const Activities: React.FC = ({}) => {
{activity.actor.name} - {getActorUsername(activity.actor)} + {getUsername(activity.actor)}
-
{getActivityDescription(activity)}
+
{getActivityDescription(activity, activityObjectsMap)}
+ {getExtendedDescription(activity)}
- {isFollower(activity.actor.id, followers) === false && ( + {isFollower(activity.actor.id) === false && (