Skip to content

Commit

Permalink
Updated thread implementation to work with posts instead of activities
Browse files Browse the repository at this point in the history
refs [AP-721](https://linear.app/ghost/issue/AP-721/update-getactivitythread-to-work-with-posts-instead-of-activities)

Updated thread implementation to work with posts instead of activities as part
of the posts migration
  • Loading branch information
mike182uk committed Feb 27, 2025
1 parent 2136aa3 commit 160bd6c
Show file tree
Hide file tree
Showing 10 changed files with 614 additions and 175 deletions.
47 changes: 46 additions & 1 deletion features/step_definitions/stepdefs.js
Original file line number Diff line number Diff line change
Expand Up @@ -595,8 +595,25 @@ When('we request the outbox', async function () {
});

When('an authenticated request is made to {string}', async function (path) {
let requestPath = path;

// If this is a request to the /thread/ endpoint, we need to replace the
// object name with the object ID as we don't have a way to know the object
// ID ahead of time
if (path.includes('/thread/')) {
const objectName = path.split('/').pop(); // Object name is the last part of the path
const object = this.objects[objectName];

if (object) {
requestPath = path.replace(
objectName,
encodeURIComponent(object.id),
);
}
}

this.response = await fetchActivityPub(
`http://fake-ghost-activitypub${path}`,
`http://fake-ghost-activitypub${requestPath}`,
{
headers: {
Accept: 'application/ld+json',
Expand Down Expand Up @@ -1624,6 +1641,7 @@ When(
const activity = await this.response.clone().json();

this.activities[replyName] = activity;
this.objects[replyName] = activity.object;
}
},
);
Expand Down Expand Up @@ -1717,3 +1735,30 @@ Then(
}
},
);

Then(
'post {string} in the thread is {string}',
async function (postNumber, objectName) {
const responseJson = await this.response.clone().json();

const object = this.objects[objectName];
const post = responseJson.posts[Number(postNumber) - 1];

assert(post, `Expected to find ${objectName} in thread`);

assert(
post.url === object.id,
`Expected ${objectName} to be at position ${postNumber} in thread`,
);
},
);

Then('the thread contains {string} posts', async function (string) {
const responseJson = await this.response.clone().json();

assert.equal(
responseJson.posts.length,
Number(string),
`Expected thread to contain ${string} posts, but got ${responseJson.posts.length}`,
);
});
66 changes: 66 additions & 0 deletions features/thread.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
@only
Feature: Thread
In order to see replies to a post
As a user
I want to request the thread for a post

Background:
Given an Actor "Person(Alice)"
And a "Follow(Us)" Activity "Follow" by "Alice"
And "Alice" sends "Follow" to the Inbox
And "Follow" is in our Inbox
And we follow "Alice"
And the request is accepted
And a "Accept(Follow(Alice))" Activity "Accept" by "Alice"
And "Alice" sends "Accept" to the Inbox
And "Accept" is in our Inbox
And a "Note" Object "Article" by "Alice"
And a "Create(Article)" Activity "Create" by "Alice"
And "Alice" sends "Create" to the Inbox
And "Create" is in our Inbox

Scenario: Retrieving the thread for a top level post
Given we reply "Reply1" to "Article" with the content
"""
This is a great article!
"""
And "Reply1" is in our Outbox
And we reply "Reply2" to "Article" with the content
"""
This is still a great article!
"""
And "Reply2" is in our Outbox
And we reply "Reply3" to "Reply1" with the content
"""
This is a great reply!
"""
And "Reply3" is in our Outbox
When an authenticated request is made to "/.ghost/activitypub/thread/Article"
Then the request is accepted
And the thread contains "3" posts
And post "1" in the thread is "Article"
And post "2" in the thread is "Reply1"
And post "3" in the thread is "Reply2"

Scenario: Retrieving the thread for a reply to a post
Given we reply "Reply1" to "Article" with the content
"""
This is a great article!
"""
And "Reply1" is in our Outbox
And we reply "Reply2" to "Article" with the content
"""
This is still a great article!
"""
And "Reply2" is in our Outbox
And we reply "Reply3" to "Reply1" with the content
"""
This is a great reply!
"""
And "Reply3" is in our Outbox
When an authenticated request is made to "/.ghost/activitypub/thread/Reply3"
Then the request is accepted
And the thread contains "3" posts
And post "1" in the thread is "Article"
And post "2" in the thread is "Reply1"
And post "3" in the thread is "Reply3"
6 changes: 3 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,11 @@ import {
createGetProfileFollowersHandler,
createGetProfileFollowingHandler,
createGetProfileHandler,
createGetThreadHandler,
createPostPublishedWebhookHandler,
createSearchHandler,
handleCreateNote,
handleGetActivities,
handleGetActivityThread,
handleGetProfilePosts,
handleWebhookSiteChanged,
} from './http/api';
Expand Down Expand Up @@ -916,8 +916,8 @@ app.get(
spanWrapper(handleGetProfilePosts),
);
app.get(
'/.ghost/activitypub/thread/:object_id',
spanWrapper(handleGetActivityThread),
'/.ghost/activitypub/thread/:post_ap_id',
spanWrapper(createGetThreadHandler(postRepository, accountService)),
);
app.get(
'/.ghost/activitypub/account/:handle',
Expand Down
74 changes: 0 additions & 74 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,32 +141,6 @@ export async function getActivityMetaWithoutJoin(
return map;
}

export async function getActivityChildren(activity: ActivityJsonLd) {
const objectId = activity.object.id;

const results = await client
.select('value')
.from('key_value')
.where(function () {
// If inReplyTo is a string
this.where(
client.raw(
`JSON_EXTRACT(value, "$.object.inReplyTo") = "${objectId}"`,
),
);

// If inReplyTo is an object
this.orWhere(
client.raw(
`JSON_EXTRACT(value, "$.object.inReplyTo.id") = "${objectId}"`,
),
);
})
.andWhere(client.raw(`JSON_EXTRACT(value, "$.type") = "Create"`));

return results.map((result) => result.value);
}

export async function getActivityChildrenCount(activity: ActivityJsonLd) {
const objectId = activity.object.id;

Expand Down Expand Up @@ -210,51 +184,3 @@ export async function getRepostCount(activity: ActivityJsonLd) {

return result[0].count;
}

export async function getActivityParents(activity: ActivityJsonLd) {
const parents: ActivityJsonLd[] = [];

const getParent = async (objectId: string) => {
const result = await client
.select('value')
.from('key_value')
.where(
client.raw(
`JSON_EXTRACT(value, "$.object.id") = "${objectId}"`,
),
)
.andWhere(client.raw(`JSON_EXTRACT(value, "$.type") = "Create"`));

if (result.length === 1) {
const parent = result[0];

parents.unshift(parent.value);

// inReplyTo can be a string or an object
const inReplyToId =
parent.value.object.inReplyTo?.id ??
parent.value.object.inReplyTo;

if (inReplyToId) {
await getParent(inReplyToId);
}
}
};

await getParent(
// inReplyTo can be a string or an object
activity.object.inReplyTo?.id ?? activity.object.inReplyTo,
);

return parents;
}

export async function getActivityForObject(objectId: string) {
const result = await client
.select('value')
.from('key_value')
.where(client.raw(`JSON_EXTRACT(value, "$.object.id") = "${objectId}"`))
.andWhere(client.raw(`JSON_EXTRACT(value, "$.type") = "Create"`));

return result[0].value;
}
79 changes: 79 additions & 0 deletions src/http/api/__snapshots__/thread.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{
"posts": [
{
"attachments": [],
"author": {
"avatarUrl": "https://example.com/avatars/foobar.png",
"handle": "foobar",
"id": "https://example.com/users/456",
"name": "Foo Bar",
"url": "https://example.com/users/456",
},
"content": "Test Content 1",
"excerpt": "Test Excerpt 1",
"featureImageUrl": "https://example.com/feature-images/post-1.jpg",
"id": "https://example.com/.ghost/activitypub/article/aaa-bbb-ccc",
"likeCount": 0,
"likedByMe": false,
"publishedAt": "2025-02-27T15:40:00.000Z",
"readingTimeMinutes": 1,
"replyCount": 0,
"repostCount": 0,
"repostedBy": null,
"repostedByMe": false,
"title": "Test Post 1",
"type": 1,
"url": "https://example.com/posts/1",
},
{
"attachments": [],
"author": {
"avatarUrl": "https://example.com/avatars/foobar.png",
"handle": "foobar",
"id": "https://example.com/users/456",
"name": "Foo Bar",
"url": "https://example.com/users/456",
},
"content": "Test Content 2",
"excerpt": "Test Excerpt 2",
"featureImageUrl": "https://example.com/feature-images/post-2.jpg",
"id": "https://example.com/.ghost/activitypub/article/ddd-eee-fff",
"likeCount": 0,
"likedByMe": true,
"publishedAt": "2025-02-27T15:40:00.000Z",
"readingTimeMinutes": 1,
"replyCount": 0,
"repostCount": 0,
"repostedBy": null,
"repostedByMe": false,
"title": "Test Post 2",
"type": 1,
"url": "https://example.com/posts/2",
},
{
"attachments": [],
"author": {
"avatarUrl": "https://example.com/avatars/foobar.png",
"handle": "foobar",
"id": "https://example.com/users/456",
"name": "Foo Bar",
"url": "https://example.com/users/456",
},
"content": "Test Content 3",
"excerpt": "Test Excerpt 3",
"featureImageUrl": "https://example.com/feature-images/post-3.jpg",
"id": "https://example.com/.ghost/activitypub/article/ggg-hhh-iii",
"likeCount": 0,
"likedByMe": false,
"publishedAt": "2025-02-27T15:40:00.000Z",
"readingTimeMinutes": 1,
"replyCount": 0,
"repostCount": 0,
"repostedBy": null,
"repostedByMe": true,
"title": "Test Post 3",
"type": 1,
"url": "https://example.com/posts/3",
},
],
}
Loading

0 comments on commit 160bd6c

Please sign in to comment.