diff --git a/features/feed.feature b/features/feed.feature new file mode 100644 index 00000000..7a862754 --- /dev/null +++ b/features/feed.feature @@ -0,0 +1,130 @@ +Feature: Feed + In order to see posts from accounts I follow + As a user + I want to query my feed + + Background: + Given an Actor "Person(Alice)" + 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 + + Scenario: Querying the inbox + Given a "Create(Article)" Activity "Article1" by "Alice" + And "Alice" sends "Article1" to the Inbox + And "Article1" is in our Inbox + And a "Create(Note)" Activity "Note1" by "Alice" + And "Alice" sends "Note1" to the Inbox + And "Note1" is in our Inbox + When an authenticated request is made to "/.ghost/activitypub/inbox" + Then the request is accepted + And the feed contains "Article1" + And the feed does not contain "Note1" + + Scenario: Querying the feed filtered by type: Note + Given a "Create(Note)" Activity "Note1" by "Alice" + And "Alice" sends "Note1" to the Inbox + And "Note1" is in our Inbox + And a "Create(Article)" Activity "Article1" by "Alice" + And "Alice" sends "Article1" to the Inbox + And "Article1" is in our Inbox + When an authenticated request is made to "/.ghost/activitypub/feed" + Then the request is accepted + And the feed contains "Note1" + And the feed does not contain "Article1" + + Scenario: Feed only includes posts + Given a "Create(Note)" Activity "Note1" by "Alice" + And "Alice" sends "Note1" to the Inbox + And "Note1" is in our Inbox + And a "Create(Article)" Activity "Article1" by "Alice" + And "Alice" sends "Article1" to the Inbox + And "Article1" is in our Inbox + And a "Like(Note1)" Activity "Like1" by "Alice" + And "Alice" sends "Like1" to the Inbox + And "Like1" is in our Inbox + When an authenticated request is made to "/.ghost/activitypub/feed" + Then the request is accepted + And the feed contains "Note1" + And the feed does not contain "Like1" + When an authenticated request is made to "/.ghost/activitypub/inbox" + Then the request is accepted + And the feed contains "Article1" + And the feed does not contain "Like1" + + Scenario: Feed is paginated + Given a "Create(Note)" Activity "Note1" by "Alice" + And "Alice" sends "Note1" to the Inbox + And "Note1" is in our Inbox + And a "Create(Note)" Activity "Note2" by "Alice" + And "Alice" sends "Note2" to the Inbox + And "Note2" is in our Inbox + And a "Create(Note)" Activity "Note3" by "Alice" + And "Alice" sends "Note3" to the Inbox + And "Note3" is in our Inbox + When an authenticated request is made to "/.ghost/activitypub/feed?limit=2" + Then the request is accepted + And the feed contains "Note3" + And the feed contains "Note2" + And the feed does not contain "Note1" + And the feed has a next cursor + When an authenticated request is made to "/.ghost/activitypub/feed?limit=3" + Then the request is accepted + And the feed contains "Note1" + + Scenario: Requests with limit over 100 are rejected + When an authenticated request is made to "/.ghost/activitypub/feed?limit=200" + Then the request is rejected with a 400 + + Scenario: Feed includes our own posts + When we create a note "Note1" with the content + """ + Hello World + """ + When an authenticated request is made to "/.ghost/activitypub/feed" + Then the request is accepted + And the feed contains "Note1" + + Scenario: Feed includes posts we reposted + Given a "Create(Note)" Activity "Note1" by "Alice" + And "Alice" sends "Note1" to the Inbox + And "Note1" is in our Inbox + And we repost the object "Note1" + And the request is accepted + When an authenticated request is made to "/.ghost/activitypub/feed" + Then the request is accepted + And the feed contains "Note1" + + Scenario: Feed includes posts from followed accounts + Given a "Create(Note)" Activity "Note1" by "Alice" + And "Alice" sends "Note1" to the Inbox + And "Note1" is in our Inbox + When an authenticated request is made to "/.ghost/activitypub/feed" + Then the request is accepted + And the feed contains "Note1" + + Scenario: Feed includes reposts from followed accounts + Given an Actor "Person(Bob)" + And a "Note" Object "Note1" by "Bob" + And a "Announce(Note1)" Activity "Repost1" by "Alice" + And "Alice" sends "Repost1" to the Inbox + And "Repost1" is in our Inbox + When an authenticated request is made to "/.ghost/activitypub/feed" + Then the request is accepted + And the feed contains "Note1" + + Scenario: Feed excludes replies + Given a "Create(Note)" Activity "Note1" by "Alice" + And "Alice" sends "Note1" to the Inbox + And "Note1" is in our Inbox + And a "Note" Object "Reply1" by "Alice" + And "Reply1" is a reply to "Note1" + And a "Create(Reply1)" Activity "ReplyCreate" by "Alice" + And "Alice" sends "ReplyCreate" to the Inbox + And "ReplyCreate" is in our Inbox + When an authenticated request is made to "/.ghost/activitypub/feed" + Then the request is accepted + And the feed contains "Note1" + And the feed does not contain "ReplyCreate" diff --git a/features/step_definitions/stepdefs.js b/features/step_definitions/stepdefs.js index 04e50304..7ee0908c 100644 --- a/features/step_definitions/stepdefs.js +++ b/features/step_definitions/stepdefs.js @@ -1662,3 +1662,51 @@ Given('{string} has Object {string}', function (activityName, objectName) { this.activities[activityName] = { ...activity, object }; }); + +When('we request the feed with the next cursor', async function () { + const responseJson = await this.response.clone().json(); + const nextCursor = responseJson.next; + + this.response = await fetchActivityPub( + `http://fake-ghost-activitypub/.ghost/activitypub/feed/index?next=${encodeURIComponent(nextCursor)}`, + { + headers: { + Accept: 'application/json', + }, + }, + ); +}); + +Then('the feed contains {string}', async function (activityOrObjectName) { + const responseJson = await this.response.clone().json(); + const activity = this.activities[activityOrObjectName]; + const object = this.objects[activityOrObjectName]; + let found; + + if (activity) { + found = responseJson.posts.find( + (post) => post.id === activity.object.id, + ); + } else if (object) { + found = responseJson.posts.find((post) => post.id === object.id); + } + + assert(found, `Expected to find ${activityOrObjectName} in feed`); +}); + +Then('the feed does not contain {string}', async function (activityName) { + const responseJson = await this.response.clone().json(); + const activity = this.activities[activityName]; + + const found = responseJson.posts.find( + (post) => post.id === activity.object.id, + ); + + assert(!found, `Expected not to find ${activityName} in feed`); +}); + +Then('the feed has a next cursor', async function () { + const responseJson = await this.response.clone().json(); + + assert(responseJson.next, 'Expected feed to have a next cursor'); +}); diff --git a/src/app.ts b/src/app.ts index 73454bcd..e6869177 100644 --- a/src/app.ts +++ b/src/app.ts @@ -32,6 +32,7 @@ import { withContext, } from '@logtape/logtape'; import * as Sentry from '@sentry/node'; +import { PostType } from 'feed/types'; import { Hono, type Context as HonoContext, type Next } from 'hono'; import { cors } from 'hono/cors'; import jwt from 'jsonwebtoken'; @@ -846,7 +847,7 @@ app.get( spanWrapper(handleGetProfilePosts), ); app.get( - '/.ghost/activitypub/thread/:activity_id', + '/.ghost/activitypub/thread/:object_id', spanWrapper(handleGetActivityThread), ); app.get( @@ -862,7 +863,16 @@ app.get( app.get( '/.ghost/activitypub/feed', requireRole(GhostRole.Owner), - spanWrapper(createGetFeedHandler(feedService, accountService)), + spanWrapper( + createGetFeedHandler(feedService, accountService, PostType.Note), + ), +); +app.get( + '/.ghost/activitypub/inbox', + requireRole(GhostRole.Owner), + spanWrapper( + createGetFeedHandler(feedService, accountService, PostType.Article), + ), ); /** Federation wire up */ diff --git a/src/constants.ts b/src/constants.ts index a9bf4153..fbb5a873 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,6 +5,8 @@ export const ACTOR_DEFAULT_NAME = 'Local Ghost site'; export const ACTOR_DEFAULT_ICON = 'https://ghost.org/favicon.ico'; export const ACTOR_DEFAULT_SUMMARY = 'This is a summary'; +export const ACTIVITY_TYPE_ANNOUNCE = 'Announce'; +export const ACTIVITY_TYPE_CREATE = 'Create'; export const ACTIVITY_OBJECT_TYPE_ARTICLE = 'Article'; export const ACTIVITY_OBJECT_TYPE_NOTE = 'Note'; diff --git a/src/db.ts b/src/db.ts index c970cbd7..5c017943 100644 --- a/src/db.ts +++ b/src/db.ts @@ -96,6 +96,51 @@ export async function getActivityMeta( return map; } +// This is a variant of getActivityMeta that does not use a join on itself in +// order to also fetch replies. This is fixes a long standing issue where replies +// are not correctly being fetched. This is used by the new feed endpoint which +// does not need to fetch replies. Why not just update getActivityMeta? That +// method is used by the notifications section of the client which needs replies +// and somehow works around its quirks of them not being correctly fetched :s +// Seeming though this is method is only going to be temporary until we have the +// posts table, I thought it would be easier to do this instead of updating +// getActivityMeta and potentially breaking notifications +export async function getActivityMetaWithoutJoin( + uris: string[], +): Promise> { + const results = await client + .select( + 'key', + 'id', + client.raw('JSON_EXTRACT(value, "$.actor.id") as actor_id'), + client.raw('JSON_EXTRACT(value, "$.type") as activity_type'), + client.raw('JSON_EXTRACT(value, "$.object.type") as object_type'), + client.raw( + 'COALESCE(JSON_EXTRACT(value, "$.object.inReplyTo.id"), JSON_EXTRACT(value, "$.object.inReplyTo")) as reply_object_url', + ), + ) + .from('key_value') + .whereIn( + 'key', + uris.map((uri) => `["${uri}"]`), + ); + + const map = new Map(); + + for (const result of results) { + map.set(result.key.substring(2, result.key.length - 2), { + id: result.id, + actor_id: result.actor_id, + activity_type: result.activity_type, + object_type: result.object_type, + reply_object_url: result.reply_object_url, + reply_object_name: '', + }); + } + + return map; +} + export async function getActivityChildren(activity: ActivityJsonLd) { const objectId = activity.object.id; @@ -203,3 +248,13 @@ export async function getActivityParents(activity: ActivityJsonLd) { 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; +} diff --git a/src/feed/feed.service.ts b/src/feed/feed.service.ts index 5474078a..b125cc79 100644 --- a/src/feed/feed.service.ts +++ b/src/feed/feed.service.ts @@ -4,8 +4,10 @@ import type { FedifyRequestContext } from '../app'; import { ACTIVITY_OBJECT_TYPE_ARTICLE, ACTIVITY_OBJECT_TYPE_NOTE, + ACTIVITY_TYPE_ANNOUNCE, + ACTIVITY_TYPE_CREATE, } from '../constants'; -import { getActivityMeta } from '../db'; +import { getActivityMetaWithoutJoin } from '../db'; import { type Activity, buildActivity } from '../helpers/activitypub/activity'; import { spanWrapper } from '../instrumentation'; import { PostType } from './types'; @@ -59,32 +61,45 @@ export class FeedService { let activityRefs = [...inboxRefs, ...outboxRefs]; - // Filter the activityRefs by the provided post type - const activityMeta = await getActivityMeta(activityRefs); - activityRefs = activityRefs - // If we can't find the meta data in the database for an activity, we - // skip it as this is unexpected - .filter((ref) => activityMeta.has(ref)) - // Filter the activityRefs by the provided post type if provided. If - // no post type is provided, we include all articles and notes - .filter((ref) => { - const meta = activityMeta.get(ref); - - if (options.postType === null) { - return [ - ACTIVITY_OBJECT_TYPE_ARTICLE, - ACTIVITY_OBJECT_TYPE_NOTE, - ].includes(meta!.object_type); - } - - if (options.postType === PostType.Article) { - return meta!.object_type === ACTIVITY_OBJECT_TYPE_ARTICLE; - } - - if (options.postType === PostType.Note) { - return meta!.object_type === ACTIVITY_OBJECT_TYPE_NOTE; - } - }); + const activityMeta = await getActivityMetaWithoutJoin(activityRefs); + activityRefs = activityRefs.filter((ref) => { + const meta = activityMeta.get(ref); + + // If we can't find the meta data in the database for an activity, + // we skip it as this is unexpected + if (!meta) { + return false; + } + + // The feed should only contain Create and Announce activities + if ( + meta.activity_type !== ACTIVITY_TYPE_CREATE && + meta.activity_type !== ACTIVITY_TYPE_ANNOUNCE + ) { + return false; + } + + // The feed should not contain replies + if (meta.reply_object_url !== null) { + return false; + } + + // Filter by the provided post type + if (options.postType === null) { + return [ + ACTIVITY_OBJECT_TYPE_ARTICLE, + ACTIVITY_OBJECT_TYPE_NOTE, + ].includes(meta!.object_type); + } + + if (options.postType === PostType.Article) { + return meta!.object_type === ACTIVITY_OBJECT_TYPE_ARTICLE; + } + + if (options.postType === PostType.Note) { + return meta!.object_type === ACTIVITY_OBJECT_TYPE_NOTE; + } + }); // Sort the activity refs by the latest first (yes using the ID which // is totally gross but we have no other option at the moment) diff --git a/src/http/api/activities.ts b/src/http/api/activities.ts index 8adb5b87..d960af76 100644 --- a/src/http/api/activities.ts +++ b/src/http/api/activities.ts @@ -1,6 +1,7 @@ import { type AppContext, fedify } from '../../app'; import { getActivityChildren, + getActivityForObject, getActivityMeta, getActivityParents, } from '../../db'; @@ -278,19 +279,17 @@ export async function handleGetActivityThread(ctx: AppContext) { logger, }); - // Parse "activity_id" from request parameters - // /thread/:activity_id - const paramActivityId = ctx.req.param('activity_id'); - const activityId = paramActivityId - ? decodeURIComponent(paramActivityId) - : ''; + // Parse "object_id" from request parameters + // /thread/:object_id + const paramObjectId = ctx.req.param('object_id'); + const objectId = paramObjectId ? decodeURIComponent(paramObjectId) : ''; - // If the provided activityId is invalid, return early - if (isUri(activityId) === false) { + // If the provided objectId is invalid, return early + if (isUri(objectId) === false) { return new Response(null, { status: 400 }); } - const activityJsonLd = await globaldb.get([activityId]); + const activityJsonLd = await getActivityForObject(objectId); // If the activity can not be found, return early if (activityJsonLd === undefined) { diff --git a/src/http/api/feed.ts b/src/http/api/feed.ts index 6d60c50d..e4859c4f 100644 --- a/src/http/api/feed.ts +++ b/src/http/api/feed.ts @@ -1,14 +1,19 @@ import type { AccountService } from '../../account/account.service'; import { type AppContext, fedify } from '../../app'; import type { FeedService } from '../../feed/feed.service'; -import { PostType } from '../../feed/types'; +import type { PostType } from '../../feed/types'; import { mapActivityToPost } from './helpers/post'; import type { Post } from './types'; +/** + * Default number of feed posts to return + */ +const DEFAULT_FEED_POSTS_LIMIT = 20; + /** * Maximum number of feed posts to return */ -const FEED_POSTS_LIMIT = 20; +const MAX_FEED_POSTS_LIMIT = 100; /** * Create a handler to handle a request for a user's feed @@ -19,6 +24,7 @@ const FEED_POSTS_LIMIT = 20; export function createGetFeedHandler( feedService: FeedService, accountService: AccountService, + postType: PostType, ) { /** * Handle a request for a user's feed @@ -35,27 +41,25 @@ export function createGetFeedHandler( logger, }); - // Validate input - const queryType = ctx.req.query('type'); - const postType = queryType ? Number(queryType) : null; + const queryCursor = ctx.req.query('next'); + const cursor = queryCursor ? decodeURIComponent(queryCursor) : null; - if ( - postType && - [PostType.Article, PostType.Note].includes(postType) === false - ) { + const queryLimit = ctx.req.query('limit'); + const limit = queryLimit + ? Number(queryLimit) + : DEFAULT_FEED_POSTS_LIMIT; + + if (limit > MAX_FEED_POSTS_LIMIT) { return new Response(null, { status: 400, }); } - const queryCursor = ctx.req.query('cursor'); - const cursor = queryCursor ? decodeURIComponent(queryCursor) : null; - // Get feed items const { items: feedItems, nextCursor } = await feedService.getFeedFromKvStore(db, apCtx, { postType, - limit: FEED_POSTS_LIMIT, + limit, cursor, }); @@ -63,7 +67,7 @@ export function createGetFeedHandler( const posts: Post[] = []; for (const item of feedItems) { - const post = await mapActivityToPost(item, accountService); + const post = await mapActivityToPost(item, accountService, apCtx); if (post) { posts.push(post); diff --git a/src/http/api/helpers/post.ts b/src/http/api/helpers/post.ts index 073f0ca8..cb070c31 100644 --- a/src/http/api/helpers/post.ts +++ b/src/http/api/helpers/post.ts @@ -1,8 +1,14 @@ +import { isActor } from '@fedify/fedify'; import type { AccountService } from '../../../account/account.service'; -import { getAccountHandle } from '../../../account/utils'; +import { + getAccountHandle, + mapActorToExternalAccountData, +} from '../../../account/utils'; +import type { FedifyRequestContext } from '../../../app'; import { ACTIVITY_OBJECT_TYPE_ARTICLE, ACTIVITY_OBJECT_TYPE_NOTE, + ACTIVITY_TYPE_ANNOUNCE, } from '../../../constants'; import { PostType } from '../../../feed/types'; import type { @@ -10,6 +16,7 @@ import type { ActivityObject, ActivityObjectAttachment, } from '../../../helpers/activitypub/activity'; +import { lookupActor } from '../../../lookup-helpers'; import type { Post } from '../types'; /** @@ -18,10 +25,12 @@ import type { Post } from '../types'; * * @param activity Activity * @param accountService Account service instance + * @param fedifyCtx Fedify request context instance */ export async function getPostAuthor( activity: Activity, accountService: AccountService, + fedifyCtx: FedifyRequestContext, ) { let activityPubId: string; @@ -31,19 +40,61 @@ export async function getPostAuthor( activityPubId = activity.actor.id; } - if (activity.attributedTo && typeof activity.attributedTo === 'string') { - activityPubId = activity.attributedTo; + const object = activity.object as ActivityObject; + + if (object.attributedTo && typeof object.attributedTo === 'string') { + activityPubId = object.attributedTo; } - if (activity.attributedTo && typeof activity.attributedTo === 'object') { - activityPubId = activity.attributedTo.id; + if (object.attributedTo && typeof object.attributedTo === 'object') { + activityPubId = object.attributedTo.id; } - const author = await accountService.getAccountByApId(activityPubId); + let author = await accountService.getAccountByApId(activityPubId); + + // If we can't find an author, and the activity is an announce, we need to + // look up the actor and create a new account, as we may not have created + // the account yet - currently, accounts only get created when a follow + // occurs, but the user may not be following the original author of the + // announced object. This won't be needed when we have the posts table as + // this enforces that a post belongs to an account (so the account has to + // exist prior to insertion into the posts table) + if (!author && activity.type === ACTIVITY_TYPE_ANNOUNCE) { + const actor = await lookupActor(fedifyCtx, activityPubId); + + if (isActor(actor)) { + const externalAccountData = + await mapActorToExternalAccountData(actor); + + author = + await accountService.createExternalAccount(externalAccountData); + } + } return author; } +/** + * Get the author of a post from an activity without attribution + * + * @param activity Activity + * @param accountService Account service instance + */ +export async function getPostAuthorWithoutAttribution( + activity: Activity, + accountService: AccountService, +) { + let activityPubId: string; + + if (typeof activity.actor === 'string') { + activityPubId = activity.actor; + } else { + activityPubId = activity.actor.id; + } + + return accountService.getAccountByApId(activityPubId); +} + /** * Get the excerpt of a post from an activity * @@ -139,10 +190,12 @@ export function getPostAttachments( * * @param activity Activity * @param accountService Account service instance + * @param fedifyCtx Fedify request context instance */ export async function mapActivityToPost( activity: Activity, accountService: AccountService, + fedifyCtx: FedifyRequestContext, ): Promise { const object = activity.object as ActivityObject; @@ -150,7 +203,7 @@ export async function mapActivityToPost( // content, so we need to handle this case by using an empty string const postContent = object.content || ''; - const author = await getPostAuthor(activity, accountService); + const author = await getPostAuthor(activity, accountService, fedifyCtx); // If we can't find an author, we can't map the activity to a post, so we // return early @@ -158,8 +211,7 @@ export async function mapActivityToPost( return null; } - return { - // At the moment we don't have an internal ID so just use the Fediverse ID + const post: Post = { id: object.id, type: object.type === ACTIVITY_OBJECT_TYPE_ARTICLE @@ -170,13 +222,18 @@ export async function mapActivityToPost( content: postContent, url: object.url, featureImageUrl: getPostFeatureImageUrl(activity), - publishedAt: object.published, + // When the activity is an announce, we want to use the published date of + // the announce rather than the published date of the object + publishedAt: + activity.type === ACTIVITY_TYPE_ANNOUNCE + ? activity.published + : object.published, // `buildActivity` adds a `liked` property to the object if it // has been liked by the current user likeCount: object.liked ? 1 : 0, - likedByMe: object.liked, + likedByMe: Boolean(object.liked), // `buildActivity` adds a `replyCount` property to the object - replyCount: object.replyCount, + replyCount: object.replyCount || 0, readingTimeMinutes: getPostContentReadingTimeMinutes(postContent), attachments: getPostAttachments(activity), author: { @@ -189,6 +246,31 @@ export async function mapActivityToPost( name: author.name ?? '', url: author.url ?? '', }, - sharedBy: null, + repostCount: object.repostCount ?? 0, + repostedByMe: Boolean(object.reposted), + repostedBy: null, }; + + if (activity.type === ACTIVITY_TYPE_ANNOUNCE) { + const repostedBy = await getPostAuthorWithoutAttribution( + activity, + accountService, + ); + + if (repostedBy) { + post.repostedBy = { + id: repostedBy.id.toString(), + handle: getAccountHandle( + new URL(repostedBy.ap_id).host, + repostedBy.username, + ), + avatarUrl: repostedBy.avatar_url ?? '', + name: repostedBy.name ?? '', + url: repostedBy.url ?? '', + }; + post.repostCount = 1; + } + } + + return post; } diff --git a/src/http/api/helpers/post.unit.test.ts b/src/http/api/helpers/post.unit.test.ts index 876f04ee..6a67341f 100644 --- a/src/http/api/helpers/post.unit.test.ts +++ b/src/http/api/helpers/post.unit.test.ts @@ -1,15 +1,18 @@ import { describe, expect, it, vi } from 'vitest'; import type { AccountService } from '../../../account/account.service'; +import type { FedifyRequestContext } from '../../../app'; import { ACTIVITY_OBJECT_TYPE_ARTICLE, ACTIVITY_OBJECT_TYPE_NOTE, + ACTIVITY_TYPE_ANNOUNCE, } from '../../../constants'; import { PostType } from '../../../feed/types'; import type { Activity } from '../../../helpers/activitypub/activity'; import { getPostAttachments, getPostAuthor, + getPostAuthorWithoutAttribution, getPostContentReadingTimeMinutes, getPostExcerpt, getPostFeatureImageUrl, @@ -20,6 +23,7 @@ describe('getPostAuthor', () => { it('should return the correct author if the activity actor is a string', async () => { const activity = { actor: 'https://example.com/users/foo', + object: {}, } as unknown as Activity; const expectedAccount = { @@ -36,7 +40,13 @@ describe('getPostAuthor', () => { }), } as unknown as AccountService; - const result = await getPostAuthor(activity, mockAccountService); + const fedifyCtx = {} as FedifyRequestContext; + + const result = await getPostAuthor( + activity, + mockAccountService, + fedifyCtx, + ); expect(result).toEqual(expectedAccount); }); @@ -46,6 +56,7 @@ describe('getPostAuthor', () => { actor: { id: 'https://example.com/users/foo', }, + object: {}, } as unknown as Activity; const expectedAccount = { @@ -62,7 +73,13 @@ describe('getPostAuthor', () => { }), } as unknown as AccountService; - const result = await getPostAuthor(activity, mockAccountService); + const fedifyCtx = {} as FedifyRequestContext; + + const result = await getPostAuthor( + activity, + mockAccountService, + fedifyCtx, + ); expect(result).toEqual(expectedAccount); }); @@ -70,7 +87,9 @@ describe('getPostAuthor', () => { it('should return the correct author if the activity has attribution as a string', async () => { const activity = { actor: 'https://example.com/users/foo', - attributedTo: 'https://example.com/users/bar', + object: { + attributedTo: 'https://example.com/users/bar', + }, } as unknown as Activity; const actorAccount = { @@ -86,7 +105,11 @@ describe('getPostAuthor', () => { return actorAccount; } - if (id === activity.attributedTo) { + if ( + typeof activity.object === 'object' && + activity.object.attributedTo && + id === activity.object.attributedTo + ) { return attributedAccount; } @@ -94,7 +117,13 @@ describe('getPostAuthor', () => { }), } as unknown as AccountService; - const result = await getPostAuthor(activity, mockAccountService); + const fedifyCtx = {} as FedifyRequestContext; + + const result = await getPostAuthor( + activity, + mockAccountService, + fedifyCtx, + ); expect(result).toEqual(attributedAccount); }); @@ -102,8 +131,10 @@ describe('getPostAuthor', () => { it('should return the correct author if the activity has attribution as an object', async () => { const activity = { actor: 'https://example.com/users/foo', - attributedTo: { - id: 'https://example.com/users/bar', + object: { + attributedTo: { + id: 'https://example.com/users/bar', + }, }, } as unknown as Activity; @@ -120,7 +151,11 @@ describe('getPostAuthor', () => { return actorAccount; } - if (id === activity.attributedTo.id) { + if ( + typeof activity.object === 'object' && + activity.object.attributedTo && + id === activity.object.attributedTo.id + ) { return attributedAccount; } @@ -128,16 +163,81 @@ describe('getPostAuthor', () => { }), } as unknown as AccountService; - const result = await getPostAuthor(activity, mockAccountService); + const fedifyCtx = {} as FedifyRequestContext; + + const result = await getPostAuthor( + activity, + mockAccountService, + fedifyCtx, + ); expect(result).toEqual(attributedAccount); }); }); +describe('getPostAuthorWithoutAttribution', () => { + it('should return the correct author if the activity actor is a string', async () => { + const activity = { + actor: 'https://example.com/users/foo', + } as unknown as Activity; + + const expectedAccount = { + ap_id: 'https://example.com/users/foo', + }; + + const mockAccountService = { + getAccountByApId: vi.fn().mockImplementation((id) => { + if (id === activity.actor) { + return expectedAccount; + } + + return null; + }), + } as unknown as AccountService; + + const result = await getPostAuthorWithoutAttribution( + activity, + mockAccountService, + ); + + expect(result).toEqual(expectedAccount); + }); + + it('should return the correct author if the activity actor is an object', async () => { + const activity = { + actor: { + id: 'https://example.com/users/foo', + }, + } as unknown as Activity; + + const expectedAccount = { + ap_id: 'https://example.com/users/foo', + }; + + const mockAccountService = { + getAccountByApId: vi.fn().mockImplementation((id) => { + if (id === activity.actor.id) { + return expectedAccount; + } + + return null; + }), + } as unknown as AccountService; + + const result = await getPostAuthorWithoutAttribution( + activity, + mockAccountService, + ); + + expect(result).toEqual(expectedAccount); + }); +}); + describe('getPostExcerpt', () => { it('should return an empty string if the activity object is a note', async () => { const activity = { object: { + content: 'foo bar baz', type: ACTIVITY_OBJECT_TYPE_NOTE, }, } as unknown as Activity; @@ -339,7 +439,13 @@ describe('mapActivityToPost', () => { }, } as unknown as Activity; - const result = await mapActivityToPost(activity, mockAccountService); + const fedifyCtx = {} as FedifyRequestContext; + + const result = await mapActivityToPost( + activity, + mockAccountService, + fedifyCtx, + ); expect(result).toEqual({ id: 'https://example.com/posts/123', @@ -369,7 +475,9 @@ describe('mapActivityToPost', () => { name: 'Foo Bar Baz', url: 'https://example.com/users/foobarbaz', }, - sharedBy: null, + repostedBy: null, + repostCount: 0, + repostedByMe: false, }); }); @@ -385,7 +493,13 @@ describe('mapActivityToPost', () => { }, } as unknown as Activity; - const result = await mapActivityToPost(activity, mockAccountService); + const fedifyCtx = {} as FedifyRequestContext; + + const result = await mapActivityToPost( + activity, + mockAccountService, + fedifyCtx, + ); expect(result).toEqual({ id: 'https://example.com/posts/123', @@ -397,8 +511,8 @@ describe('mapActivityToPost', () => { featureImageUrl: null, publishedAt: '2024-01-01T00:00:00Z', likeCount: 0, - likedByMe: undefined, - replyCount: undefined, + likedByMe: false, + replyCount: 0, readingTimeMinutes: 1, attachments: [], author: { @@ -408,7 +522,9 @@ describe('mapActivityToPost', () => { name: 'Foo Bar Baz', url: 'https://example.com/users/foobarbaz', }, - sharedBy: null, + repostedBy: null, + repostCount: 0, + repostedByMe: false, }); }); @@ -423,7 +539,13 @@ describe('mapActivityToPost', () => { }, } as unknown as Activity; - const result = await mapActivityToPost(activity, mockAccountService); + const fedifyCtx = {} as FedifyRequestContext; + + const result = await mapActivityToPost( + activity, + mockAccountService, + fedifyCtx, + ); expect(result?.content).toBe(''); expect(result?.readingTimeMinutes).toBe(0); @@ -443,11 +565,85 @@ describe('mapActivityToPost', () => { }, } as unknown as Activity; + const fedifyCtx = {} as FedifyRequestContext; + const result = await mapActivityToPost( activity, mockAccountServiceNoAuthor, + fedifyCtx, ); expect(result).toBeNull(); }); + + it('should set the repostedBy property if the activity is an announce', async () => { + const activity = { + actor: 'https://example.com/users/bazbarqux', + type: ACTIVITY_TYPE_ANNOUNCE, + object: { + id: 'https://example.com/posts/123', + type: ACTIVITY_TYPE_ANNOUNCE, + content: 'Test', + attributedTo: 'https://example.com/users/foobarbaz', + }, + } as unknown as Activity; + + const mockReposter = { + id: 456, + ap_id: 'https://example.com/users/bazbarqux', + username: 'bazbarqux', + avatar_url: 'https://example.com/avatars/bazbarqux.jpg', + name: 'Baz Bar Qux', + url: 'https://example.com/users/bazbarqux', + }; + + const mockAccountService = { + getAccountByApId: vi.fn().mockImplementation((id) => { + if (id === activity.actor) { + return mockReposter; + } + + return mockAuthor; + }), + } as unknown as AccountService; + + const fedifyCtx = {} as FedifyRequestContext; + + const result = await mapActivityToPost( + activity, + mockAccountService, + fedifyCtx, + ); + + expect(result?.repostedBy).toEqual({ + id: '456', + handle: '@bazbarqux@example.com', + avatarUrl: 'https://example.com/avatars/bazbarqux.jpg', + name: 'Baz Bar Qux', + url: 'https://example.com/users/bazbarqux', + }); + }); + + it('should use the published date of the announce if the activity is an announce', async () => { + const activity = { + actor: 'https://example.com/users/foobarbaz', + type: ACTIVITY_TYPE_ANNOUNCE, + object: { + id: 'https://example.com/posts/123', + type: ACTIVITY_OBJECT_TYPE_NOTE, + published: '2024-01-01T00:00:00Z', + }, + published: '2024-02-02T00:00:00Z', + } as unknown as Activity; + + const fedifyCtx = {} as FedifyRequestContext; + + const result = await mapActivityToPost( + activity, + mockAccountService, + fedifyCtx, + ); + + expect(result?.publishedAt).toEqual('2024-02-02T00:00:00Z'); + }); }); diff --git a/src/http/api/types.ts b/src/http/api/types.ts index 4a9086dc..6b63727f 100644 --- a/src/http/api/types.ts +++ b/src/http/api/types.ts @@ -142,7 +142,18 @@ export interface Post { */ author: Pick; /** - * Account that shared the post + * Number of reposts of the post */ - sharedBy: null; + repostCount: number; + /** + * Whether the current user has reposted the post + */ + repostedByMe: boolean; + /** + * Account that reposted the post + */ + repostedBy: Pick< + Account, + 'id' | 'handle' | 'avatarUrl' | 'name' | 'url' + > | null; }