Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updated activitypub app in preparation for pagination #21054

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions apps/admin-x-activitypub/src/api/activitypub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
});
});
26 changes: 23 additions & 3 deletions apps/admin-x-activitypub/src/api/activitypub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,11 @@ export class ActivityPubAPI {
return new URL(`.ghost/activitypub/activities/${this.handle}`, this.apiUrl);
}

async getAllActivities(includeOwn: boolean = false): Promise<Activity[]> {
async getAllActivities(
includeOwn: boolean = false,
includeReplies: boolean = false,
filter: {type?: string[]} | null = null
): Promise<Activity[]> {
const LIMIT = 50;

const fetchActivities = async (url: URL): Promise<Activity[]> => {
Expand All @@ -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);

Expand All @@ -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);
Expand Down
76 changes: 11 additions & 65 deletions apps/admin-x-activitypub/src/components/Activities.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -21,13 +20,11 @@ enum ACTVITY_TYPE {
FOLLOW = 'Follow'
}

const getActivityDescription = (activity: Activity, activityObjectsMap: Map<string, ObjectProperties>): 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 '';
Expand Down Expand Up @@ -87,67 +84,17 @@ const getActivityBadge = (activity: Activity): AvatarBadge => {

const Activities: React.FC<ActivitiesProps> = ({}) => {
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<string, ObjectProperties>();

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<string, Activity[]>();

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);

Expand Down Expand Up @@ -177,8 +124,7 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
NiceModal.show(ArticleModal, {
object: activity.object,
actor: activity.actor,
comments: getCommentsForObject(activity.object.id),
allComments: commentsMap
comments: activity.object.replies
});
} : undefined
}
Expand All @@ -189,7 +135,7 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
<span className='mr-1 font-bold text-black'>{activity.actor.name}</span>
{getUsername(activity.actor)}
</div>
<div className=''>{getActivityDescription(activity, activityObjectsMap)}</div>
<div className=''>{getActivityDescription(activity)}</div>
{getExtendedDescription(activity)}
</div>
{isFollower(activity.actor.id) === false && (
Expand Down
37 changes: 9 additions & 28 deletions apps/admin-x-activitypub/src/components/Inbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,37 +17,18 @@ const Inbox: React.FC<InboxProps> = ({}) => {
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<string, Activity[]>();

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) {
Expand Down Expand Up @@ -92,12 +73,12 @@ const Inbox: React.FC<InboxProps> = ({}) => {
onClick={() => handleViewContent(
activity.object,
getContentAuthor(activity),
getCommentsForObject(activity.object.id)
activity.object.replies
)}
>
<FeedItem
actor={activity.actor}
comments={getCommentsForObject(activity.object.id)}
comments={activity.object.replies}
layout={layout}
object={activity.object}
type={activity.type}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export type Activity = {
type: string,
actor: ActorProperties,
object: ObjectProperties & {
inReplyTo: string | null // TODO: Move this to the ObjectProperties type
inReplyTo: ObjectProperties | string | null
replies: Activity[]
}
}

Expand Down
13 changes: 5 additions & 8 deletions apps/admin-x-activitypub/src/components/feed/ArticleModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ interface ArticleModalProps {
object: ObjectProperties;
actor: ActorProperties;
comments: Activity[];
allComments: Map<string, Activity[]>;
}

const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => {
Expand Down Expand Up @@ -73,7 +72,7 @@ const FeedItemDivider: React.FC = () => (
<div className="mx-[-32px] h-px w-[120%] bg-grey-200"></div>
);

const ArticleModal: React.FC<ArticleModalProps> = ({object, actor, comments, allComments}) => {
const ArticleModal: React.FC<ArticleModalProps> = ({object, actor, comments}) => {
const MODAL_SIZE_SM = 640;
const MODAL_SIZE_LG = 1024;

Expand All @@ -98,8 +97,7 @@ const ArticleModal: React.FC<ArticleModalProps> = ({object, actor, comments, all
modal.show({
object: previousObject,
actor: previousActor,
comments: previousComments,
allComments: allComments
comments: previousComments
});
};
const navigateForward = (nextObject: ObjectProperties, nextActor: ActorProperties, nextComments: Activity[]) => {
Expand All @@ -109,8 +107,7 @@ const ArticleModal: React.FC<ArticleModalProps> = ({object, actor, comments, all
modal.show({
object: nextObject,
actor: nextActor,
comments: nextComments,
allComments: allComments
comments: nextComments
});
};
const toggleModalSize = () => {
Expand Down Expand Up @@ -161,7 +158,7 @@ const ArticleModal: React.FC<ArticleModalProps> = ({object, actor, comments, all
<FeedItem actor={actor} last={true} layout='reply' object={object} type='Note'/> */}
{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 (
Expand All @@ -179,7 +176,7 @@ const ArticleModal: React.FC<ArticleModalProps> = ({object, actor, comments, all
/>
{hasNestedComments && <FeedItemDivider />}
{nestedComments.map((nestedComment, nestedCommentIndex) => {
const nestedNestedComments = allComments.get(nestedComment.object.id) ?? [];
const nestedNestedComments = nestedComment.object.replies ?? [];

return (
<FeedItem
Expand Down
Loading
Loading