Skip to content

Commit

Permalink
Updated activitypub app in preparation for pagination
Browse files Browse the repository at this point in the history
refs [TryGhost/ActivityPub#44](TryGhost/ActivityPub#44)

To support pagination in the activitypub app, the following changes have been made:

- Move filtering and sorting of activities to the server
- Refactor how comments are processed (comments are now returned as part of the activity)
  • Loading branch information
mike182uk committed Sep 19, 2024
1 parent 8ab7182 commit 78aff17
Show file tree
Hide file tree
Showing 6 changed files with 51 additions and 89 deletions.
14 changes: 13 additions & 1 deletion 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 Down Expand Up @@ -175,6 +179,10 @@ export class ActivityPubAPI {
nextUrl.searchParams.set('cursor', json.nextCursor);
nextUrl.searchParams.set('limit', LIMIT.toString());
nextUrl.searchParams.set('includeOwn', includeOwn.toString());
nextUrl.searchParams.set('includeReplies', includeReplies.toString());
if (filter) {
nextUrl.searchParams.set('filter', encodeURI(JSON.stringify(filter)));
}

const nextItems = await fetchActivities(nextUrl);

Expand All @@ -188,6 +196,10 @@ export class ActivityPubAPI {
const url = new URL(this.activitiesApiUrl);
url.searchParams.set('limit', LIMIT.toString());
url.searchParams.set('includeOwn', includeOwn.toString());
url.searchParams.set('includeReplies', includeReplies.toString());
if (filter) {
url.searchParams.set('filter', encodeURI(JSON.stringify(filter)));
}

// Fetch the activities
return fetchActivities(url);
Expand Down
59 changes: 10 additions & 49 deletions apps/admin-x-activitypub/src/components/Activities.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,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();

const {data: activities = []} = useAllActivitiesForUser({
handle: user,
includeOwn: true,
includeReplies: true,
filter: {
type: ['Follow', 'Like', `Create:Note:isReplyToOwn,${new URL(siteUrl).hostname}`]
}
});

// 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
Expand All @@ -103,51 +110,6 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
}
});

// 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;
}

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 +139,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 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 @@ -7,6 +7,7 @@ export type Activity = {
actor: ActorProperties,
object: ObjectProperties & {
inReplyTo: string | null // TODO: Move this to the ObjectProperties type
replies: Activity[] // TODO: Should this be in the ObjectProperties type? Is the ObjectProperties type ActivityPub specific?
}
}

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
16 changes: 13 additions & 3 deletions apps/admin-x-activitypub/src/hooks/useActivityPubQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,23 @@ export function useFollowersForUser(handle: string) {
});
}

export function useAllActivitiesForUser({handle, includeOwn = false}: {handle: string, includeOwn?: boolean}) {
export function useAllActivitiesForUser({
handle,
includeOwn = false,
includeReplies = false,
filter = null
}: {
handle: string;
includeOwn?: boolean;
includeReplies?: boolean;
filter?: {type?: string[]} | null;
}) {
const siteUrl = useSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return useQuery({
queryKey: [`activities:${handle}:includeOwn=${includeOwn.toString()}`],
queryKey: [`activities:${JSON.stringify({handle, includeOwn, includeReplies, filter})}`],
async queryFn() {
return api.getAllActivities(includeOwn);
return api.getAllActivities(includeOwn, includeReplies, filter);
}
});
}

0 comments on commit 78aff17

Please sign in to comment.