0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-01 02:41:39 -05:00

Used new feed and inbox endpoints for feed and inbox view

refs https://linear.app/ghost/issue/AP-704

We also update the mutations to make sure that the query cache is correctly updated.

Co-authored-by: Fabien O'Carroll <fabien@allou.is>
This commit is contained in:
Michael Barrett 2025-02-11 14:36:47 +00:00 committed by GitHub
parent 4f8678f526
commit ec327badf9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 743 additions and 176 deletions

View file

@ -17,6 +17,9 @@ module.exports = {
}
},
rules: {
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
// sort multiple import lines into alphabetical groups
'ghost/sort-imports-es6-autofix/sort-imports-es6': ['error', {
memberSyntaxSortOrder: ['none', 'all', 'single', 'multiple']

View file

@ -1,6 +1,6 @@
{
"name": "@tryghost/admin-x-activitypub",
"version": "0.3.63",
"version": "0.4.0",
"license": "MIT",
"repository": {
"type": "git",

View file

@ -1254,4 +1254,209 @@ describe('ActivityPubAPI', function () {
expect(actual).toEqual(expected);
});
});
describe('getFeed', function () {
test('It returns an array of posts in a feed', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
[`https://activitypub.api/.ghost/activitypub/feed`]: {
response: JSONResponse({
posts: [
{
id: 'https://example.com/posts/abc123'
},
{
id: 'https://example.com/posts/def456'
}
],
next: null
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFeed();
expect(actual.posts).toEqual([
{
id: 'https://example.com/posts/abc123'
},
{
id: 'https://example.com/posts/def456'
}
]);
});
test('It returns next if it is present in the response', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
[`https://activitypub.api/.ghost/activitypub/feed`]: {
response: JSONResponse({
posts: [],
next: 'abc123'
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFeed();
expect(actual.next).toEqual('abc123');
});
test('It includes next in the query when provided', async function () {
const next = 'abc123';
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
[`https://activitypub.api/.ghost/activitypub/feed?next=abc123`]: {
response: JSONResponse({
posts: [
{
id: 'https://example.com/posts/def456'
}
],
next: null
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFeed(next);
const expected = {
posts: [
{
id: 'https://example.com/posts/def456'
}
],
next: null
};
expect(actual).toEqual(expected);
});
test('It returns a default return value when the response is null', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
[`https://activitypub.api/.ghost/activitypub/feed`]: {
response: JSONResponse(null)
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFeed();
const expected = {
posts: [],
next: null
};
expect(actual).toEqual(expected);
});
test('It returns a default return value if posts is not present in the response', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
[`https://activitypub.api/.ghost/activitypub/feed`]: {
response: JSONResponse({})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFeed();
const expected = {
posts: [],
next: null
};
expect(actual).toEqual(expected);
});
test('It returns an empty array of posts if posts in the response is not an array', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
identities: [{
token: 'fake-token'
}]
})
},
[`https://activitypub.api/.ghost/activitypub/feed`]: {
response: JSONResponse({
posts: []
})
}
});
const api = new ActivityPubAPI(
new URL('https://activitypub.api'),
new URL('https://auth.api'),
'index',
fakeFetch
);
const actual = await api.getFeed();
expect(actual.posts).toEqual([]);
});
});
});

View file

@ -76,6 +76,44 @@ export interface GetAccountFollowsResponse {
next: string | null;
}
export enum PostType {
Article = 1,
Note = 2,
}
export interface Post {
id: string;
type: PostType;
title: string;
excerpt: string;
content: string;
url: string;
featureImageUrl: string | null;
publishedAt: string;
likeCount: number;
likedByMe: boolean;
replyCount: number;
readingTimeMinutes: number;
attachments: {
type: string;
mediaType: string;
name: string;
url: string;
}[];
author: Pick<Account, 'id' | 'handle' | 'avatarUrl' | 'name' | 'url'>;
repostCount: number;
repostedByMe: boolean;
repostedBy: Pick<
Account,
'id' | 'handle' | 'avatarUrl' | 'name' | 'url'
> | null;
}
export interface GetFeedResponse {
posts: Post[];
next: string | null;
}
export class ActivityPubAPI {
constructor(
private readonly apiUrl: URL,
@ -434,4 +472,68 @@ export class ActivityPubAPI {
next: nextPage
};
}
async getFeed(next?: string): Promise<GetFeedResponse> {
const url = new URL(`.ghost/activitypub/feed`, this.apiUrl);
if (next) {
url.searchParams.set('next', next);
}
const json = await this.fetchJSON(url);
if (json === null) {
return {
posts: [],
next: null
};
}
if (!('posts' in json)) {
return {
posts: [],
next: null
};
}
const posts = Array.isArray(json.posts) ? json.posts : [];
const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null;
return {
posts,
next: nextPage
};
}
async getInbox(next?: string): Promise<GetFeedResponse> {
const url = new URL(`.ghost/activitypub/inbox`, this.apiUrl);
if (next) {
url.searchParams.set('next', next);
}
const json = await this.fetchJSON(url);
if (json === null) {
return {
posts: [],
next: null
};
}
if (!('posts' in json)) {
return {
posts: [],
next: null
};
}
const posts = Array.isArray(json.posts) ? json.posts : [];
const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null;
return {
posts,
next: nextPage
};
}
}

View file

@ -11,16 +11,15 @@ import getName from '../utils/get-name';
import getUsername from '../utils/get-username';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Skeleton} from '@tryghost/shade';
import {
GET_ACTIVITIES_QUERY_KEY_FEED,
GET_ACTIVITIES_QUERY_KEY_INBOX,
useActivitiesForUser,
useSuggestedProfilesForUser,
useUserDataForUser
} from '../hooks/useActivityPubQueries';
import {Heading, LoadingIndicator} from '@tryghost/admin-x-design-system';
import {handleProfileClick} from '../utils/handle-profile-click';
import {handleViewContent} from '../utils/content-handlers';
import {
useFeedForUser,
useInboxForUser,
useSuggestedProfilesForUser,
useUserDataForUser
} from '../hooks/useActivityPubQueries';
import {useRouting} from '@tryghost/admin-x-framework/routing';
type Layout = 'inbox' | 'feed';
@ -32,29 +31,14 @@ interface InboxProps {
const Inbox: React.FC<InboxProps> = ({layout}) => {
const {updateRoute} = useRouting();
// Initialise activities for the inbox or feed
const typeFilter = layout === 'inbox'
? ['Create:Article', 'Announce:Article']
: ['Create:Note', 'Announce:Note'];
const {inboxQuery, updateInboxActivity} = useInboxForUser({enabled: layout === 'inbox'});
const {feedQuery, updateFeedActivity} = useFeedForUser({enabled: layout === 'feed'});
const {getActivitiesQuery, updateActivity} = useActivitiesForUser({
handle: 'index',
includeOwn: true,
filter: {
type: typeFilter
},
key: layout === 'inbox' ? GET_ACTIVITIES_QUERY_KEY_INBOX : GET_ACTIVITIES_QUERY_KEY_FEED
});
const feedQueryData = layout === 'inbox' ? inboxQuery : feedQuery;
const updateActivity = layout === 'inbox' ? updateInboxActivity : updateFeedActivity;
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = feedQueryData;
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = getActivitiesQuery;
const activities = (data?.pages.flatMap(page => page.data) ?? Array.from({length: 5}, (_, index) => ({id: `placeholder-${index}`, object: {}})))
// If there somehow are duplicate activities, filter them out so the list rendering doesn't break
.filter((activity, index, self) => index === self.findIndex(a => a.id === activity.id))
// Filter out replies
.filter((activity) => {
return !activity.object.inReplyTo;
});
const activities = (data?.pages.flatMap(page => page.posts) ?? []);
// Initialise suggested profiles
const {suggestedProfilesQuery} = useSuggestedProfilesForUser('index', 3);
@ -107,7 +91,8 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
<ul className={`mx-auto flex w-full flex-col ${layout === 'inbox' && 'mt-3'}`}>
{activities.map((activity, index) => (
<li
key={activity.id}
// eslint-disable-next-line react/no-array-index-key
key={`${activity.id}-${activity.type}-${index}`} // We are using index here as activity.id is cannot be guaranteed to be unique at the moment
data-test-view-article
>
<FeedItem

View file

@ -24,7 +24,6 @@ import {handleProfileClick} from '../utils/handle-profile-click';
interface NotificationsProps {}
// eslint-disable-next-line no-shadow
enum ACTIVITY_TYPE {
CREATE = 'Create',
LIKE = 'Like',

View file

@ -1,7 +1,7 @@
import React, {useEffect, useRef, useState} from 'react';
import NiceModal from '@ebay/nice-modal-react';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Activity,ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Heading, List, LoadingIndicator, NoValueLabel, Tab, TabView} from '@tryghost/admin-x-design-system';
import {Skeleton} from '@tryghost/shade';
@ -23,7 +23,6 @@ import FollowButton from './global/FollowButton';
import MainNavigation from './navigation/MainNavigation';
import Separator from './global/Separator';
import ViewProfileModal from './modals/ViewProfileModal';
import {type Activity} from '../components/activities/ActivityItem';
interface UseInfiniteScrollTabProps<TData> {
useDataHook: (key: string) => ActivityPubCollectionQueryResult<TData> | AccountFollowsQueryResult;

View file

@ -1,18 +1,5 @@
import React, {ReactNode} from 'react';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
export type Activity = {
id: string,
type: string,
actor: ActorProperties,
object: ObjectProperties & {
inReplyTo: ObjectProperties | string | null
replies: Activity[]
replyCount: number
}
}
interface ActivityItemProps {
children?: ReactNode;
url?: string | null;

View file

@ -6,8 +6,7 @@ import articleBodyStyles from '../articleBodyStyles';
import getUsername from '../../utils/get-username';
import {OptionProps, SingleValueProps, components} from 'react-select';
import {type Activity} from '../activities/ActivityItem';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Activity, ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Icon, LoadingIndicator, Modal, Popover, Select, SelectOption} from '@tryghost/admin-x-design-system';
import {renderTimestamp} from '../../utils/render-timestamp';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
@ -371,7 +370,7 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
const {threadQuery, addToThread} = useThreadForUser('index', activityId);
const {data: activityThread, isLoading: isLoadingThread} = threadQuery;
const activtyThreadActivityIdx = (activityThread?.items ?? []).findIndex(item => item.id === activityId);
const activtyThreadActivityIdx = (activityThread?.items ?? []).findIndex(item => item.object.id === activityId);
const activityThreadChildren = (activityThread?.items ?? []).slice(activtyThreadActivityIdx + 1);
const activityThreadParents = (activityThread?.items ?? []).slice(0, activtyThreadActivityIdx);
@ -398,12 +397,14 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
history
});
};
const navigateForward = (nextActivityId: string, nextObject: ObjectProperties, nextActor: ActorProperties, nextFocusReply: boolean) => {
const navigateForward = (_: string, nextObject: ObjectProperties, nextActor: ActorProperties, nextFocusReply: boolean) => {
// Trigger the modal to show the next activity and add the existing
// activity to the history so we can navigate back
modal.show({
activityId: nextActivityId,
// We need to use the object as the API expects an object ID but
// returns a full activity object
activityId: nextObject.id,
object: nextObject,
actor: nextActor,
updateActivity,

View file

@ -4,8 +4,7 @@ import * as FormPrimitive from '@radix-ui/react-form';
import APAvatar from './APAvatar';
import clsx from 'clsx';
import getUsername from '../../utils/get-username';
import {Activity} from '../activities/ActivityItem';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Activity, ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, showToast} from '@tryghost/admin-x-design-system';
import {useReplyMutationForUser, useUserDataForUser} from '../../hooks/useActivityPubQueries';

View file

@ -9,14 +9,16 @@ import {
type Profile,
type SearchResults
} from '../api/activitypub';
import {Activity} from '../components/activities/ActivityItem';
import {Activity} from '@tryghost/admin-x-framework/api/activitypub';
import {
type QueryClient,
type UseInfiniteQueryResult,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient
} from '@tanstack/react-query';
import {mapPostToActivity} from '../utils/posts';
export type ActivityPubCollectionQueryResult<TData> = UseInfiniteQueryResult<ActivityPubCollectionResponse<TData>>;
export type AccountFollowsQueryResult = UseInfiniteQueryResult<GetAccountFollowsResponse>;
@ -63,7 +65,9 @@ const QUERY_KEYS = {
) => ['activities', handle, key, options].filter(value => value !== undefined),
searchResults: (query: string) => ['search_results', query],
suggestedProfiles: (limit: number) => ['suggested_profiles', limit],
thread: (id: string) => ['thread', id]
thread: (id: string) => ['thread', id],
feed: ['feed'],
inbox: ['inbox']
};
export function useOutboxForUser(handle: string) {
@ -96,6 +100,36 @@ export function useLikedForUser(handle: string) {
});
}
function updateLikedCache(queryClient: QueryClient, queryKey: string[], id: string, liked: boolean) {
queryClient.setQueriesData(queryKey, (current?: {pages: {posts: Activity[]}[]}) => {
if (current === undefined) {
return current;
}
return {
...current,
pages: current.pages.map((page: {posts: Activity[]}) => {
return {
...page,
posts: page.posts.map((item: Activity) => {
if (item.object.id === id) {
return {
...item,
object: {
...item.object,
liked: liked
}
};
}
return item;
})
};
})
};
});
}
export function useLikeMutationForUser(handle: string) {
const queryClient = useQueryClient();
@ -107,35 +141,8 @@ export function useLikeMutationForUser(handle: string) {
return api.like(id);
},
onMutate: (id) => {
// Update the "liked" property of the activity stored in the activities query cache
const queryKey = QUERY_KEYS.activities(handle);
queryClient.setQueriesData(queryKey, (current?: {pages: {data: Activity[]}[]}) => {
if (current === undefined) {
return current;
}
return {
...current,
pages: current.pages.map((page: {data: Activity[]}) => {
return {
...page,
data: page.data.map((item: Activity) => {
if (item.object.id === id) {
return {
...item,
object: {
...item.object,
liked: true
}
};
}
return item;
})
};
})
};
});
updateLikedCache(queryClient, QUERY_KEYS.feed, id, true);
updateLikedCache(queryClient, QUERY_KEYS.inbox, id, true);
}
});
}
@ -151,37 +158,40 @@ export function useUnlikeMutationForUser(handle: string) {
return api.unlike(id);
},
onMutate: (id) => {
// Update the "liked" property of the activity stored in the activities query cache
const queryKey = QUERY_KEYS.activities(handle);
updateLikedCache(queryClient, QUERY_KEYS.feed, id, false);
updateLikedCache(queryClient, QUERY_KEYS.inbox, id, false);
}
});
}
queryClient.setQueriesData(queryKey, (current?: {pages: {data: Activity[]}[]}) => {
if (current === undefined) {
return current;
}
function updateRepostCache(queryClient: QueryClient, queryKey: string[], id: string, reposted: boolean) {
queryClient.setQueriesData(queryKey, (current?: {pages: {posts: Activity[]}[]}) => {
if (current === undefined) {
return current;
}
return {
...current,
pages: current.pages.map((page: {posts: Activity[]}) => {
return {
...current,
pages: current.pages.map((page: {data: Activity[]}) => {
return {
...page,
data: page.data.map((item: Activity) => {
if (item.object.id === id) {
return {
...item,
object: {
...item.object,
liked: false
}
};
...page,
posts: page.posts.map((item: Activity) => {
if (item.object.id === id) {
return {
...item,
object: {
...item.object,
reposted: reposted,
repostCount: Math.max(reposted ? item.object.repostCount + 1 : item.object.repostCount - 1, 0)
}
};
}
return item;
})
};
return item;
})
};
});
}
})
};
});
}
@ -196,37 +206,8 @@ export function useRepostMutationForUser(handle: string) {
return api.repost(id);
},
onMutate: (id) => {
// Update the "reposted" property of the activity stored in the activities query cache
const queryKey = QUERY_KEYS.activities(handle);
queryClient.setQueriesData(queryKey, (current?: {pages: {data: Activity[]}[]}) => {
if (current === undefined) {
return current;
}
return {
...current,
pages: current.pages.map((page: {data: Activity[]}) => {
return {
...page,
data: page.data.map((item: Activity) => {
if (item.object.id === id) {
return {
...item,
object: {
...item.object,
reposted: true,
repostCount: item.object.repostCount + 1
}
};
}
return item;
})
};
})
};
});
updateRepostCache(queryClient, QUERY_KEYS.feed, id, true);
updateRepostCache(queryClient, QUERY_KEYS.inbox, id, true);
}
});
}
@ -242,37 +223,8 @@ export function useDerepostMutationForUser(handle: string) {
return api.derepost(id);
},
onMutate: (id) => {
// Update the "reposted" property of the activity stored in the activities query cache
const queryKey = QUERY_KEYS.activities(handle);
queryClient.setQueriesData(queryKey, (current?: {pages: {data: Activity[]}[]}) => {
if (current === undefined) {
return current;
}
return {
...current,
pages: current.pages.map((page: {data: Activity[]}) => {
return {
...page,
data: page.data.map((item: Activity) => {
if (item.object.id === id) {
return {
...item,
object: {
...item.object,
reposted: false,
repostCount: item.object.repostCount - 1 < 0 ? 0 : item.object.repostCount - 1
}
};
}
return item;
})
};
})
};
});
updateRepostCache(queryClient, QUERY_KEYS.feed, id, false);
updateRepostCache(queryClient, QUERY_KEYS.inbox, id, false);
}
});
}
@ -704,21 +656,29 @@ export function useNoteMutationForUser(handle: string) {
};
});
// Update the activity stored in the activities query cache
const activitiesQueryKey = QUERY_KEYS.activities(handle, GET_ACTIVITIES_QUERY_KEY_FEED);
// Update the activity stored in the feed query cache
const feedQueryKey = QUERY_KEYS.feed;
queryClient.setQueriesData(activitiesQueryKey, (current?: {pages: {data: Activity[]}[]}) => {
queryClient.setQueriesData(feedQueryKey, (current?: {pages: {posts: Activity[]}[]}) => {
if (current === undefined) {
return current;
}
return {
...current,
pages: current.pages.map((page: {data: Activity[]}, index: number) => {
pages: current.pages.map((page: {posts: Activity[]}, index: number) => {
if (index === 0) {
return {
...page,
data: [activity, ...page.data]
posts: [
{
...activity,
// Use the object id as the post id as when we switchover to using
// posts fully we will not have access the activity id
id: activity.object.id
},
...page.posts
]
};
}
@ -756,3 +716,99 @@ export function useAccountFollowsForUser(handle: string, type: AccountFollowsTyp
}
});
}
export function useFeedForUser(options: {enabled: boolean}) {
const queryKey = QUERY_KEYS.feed;
const queryClient = useQueryClient();
const feedQuery = useInfiniteQuery({
queryKey,
enabled: options.enabled,
async queryFn({pageParam}: {pageParam?: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI('index', siteUrl);
return api.getFeed(pageParam).then((response) => {
return {
posts: response.posts.map(mapPostToActivity),
next: response.next
};
});
},
getNextPageParam(prevPage) {
return prevPage.next;
}
});
const updateFeedActivity = (id: string, updated: Partial<Activity>) => {
queryClient.setQueryData(queryKey, (current: {pages: {posts: Activity[]}[]} | undefined) => {
if (!current) {
return current;
}
return {
...current,
pages: current.pages.map((page: {posts: Activity[]}) => {
return {
...page,
posts: page.posts.map((item: Activity) => {
if (item.id === id) {
return {...item, ...updated};
}
return item;
})
};
})
};
});
};
return {feedQuery, updateFeedActivity};
}
export function useInboxForUser(options: {enabled: boolean}) {
const queryKey = QUERY_KEYS.inbox;
const queryClient = useQueryClient();
const inboxQuery = useInfiniteQuery({
queryKey,
enabled: options.enabled,
async queryFn({pageParam}: {pageParam?: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI('index', siteUrl);
return api.getInbox(pageParam).then((response) => {
return {
posts: response.posts.map(mapPostToActivity),
next: response.next
};
});
},
getNextPageParam(prevPage) {
return prevPage.next;
}
});
const updateInboxActivity = (id: string, updated: Partial<Activity>) => {
queryClient.setQueryData(queryKey, (current: {pages: {posts: Activity[]}[]} | undefined) => {
if (!current) {
return current;
}
return {
...current,
pages: current.pages.map((page: {posts: Activity[]}) => {
return {
...page,
posts: page.posts.map((item: Activity) => {
if (item.id === id) {
return {...item, ...updated};
}
return item;
})
};
})
};
});
};
return {inboxQuery, updateInboxActivity};
}

View file

@ -1,6 +1,6 @@
import ArticleModal from '../components/feed/ArticleModal';
import NiceModal from '@ebay/nice-modal-react';
import {type Activity} from '../components/activities/ActivityItem';
import {type Activity} from '@tryghost/admin-x-framework/api/activitypub';
export const handleViewContent = (
activity: Activity,

View file

@ -0,0 +1,122 @@
import {Activity, Post, PostType} from '../api/activitypub';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
/**
* Map a Post to an ActivityPub activity
*
* @param post The post to map to an ActivityPub activity
*/
export function mapPostToActivity(post: Post): Activity {
let activityType = '';
// If the post has been reposted, then the corresponding activity type
// is: "Announce", otherwise the activity type is: "Create"
if (post.repostedBy !== null) {
activityType = 'Announce';
} else {
activityType = 'Create';
}
const actor: ActorProperties = {
id: post.author.url,
icon: {
url: post.author.avatarUrl
},
name: post.author.name,
preferredUsername: post.author.handle.split('@')[1],
// These are not used but needed to comply with the ActorProperties type
'@context': '',
discoverable: false,
featured: '',
followers: '',
following: '',
image: {url: ''},
inbox: '',
manuallyApprovesFollowers: false,
outbox: '',
publicKey: {
id: '',
owner: '',
publicKeyPem: ''
},
published: '',
summary: '',
type: 'Person',
url: ''
};
let repostedBy: ActorProperties | null = null;
if (post.repostedBy !== null) {
repostedBy = {
id: post.repostedBy.url,
icon: {
url: post.repostedBy.avatarUrl
},
name: post.repostedBy.name,
preferredUsername: post.repostedBy.handle.split('@')[1],
// These are not used but needed to comply with the ActorProperties type
'@context': '',
discoverable: false,
featured: '',
followers: '',
following: '',
image: {url: ''},
inbox: '',
manuallyApprovesFollowers: false,
outbox: '',
publicKey: {
id: '',
owner: '',
publicKeyPem: ''
},
published: '',
summary: '',
type: 'Person',
url: ''
};
}
// If the post is an article, then the object type is: "Article"
// otherwise, we use "Note" as the object type
let objectType: 'Article' | 'Note' = 'Note';
if (post.type === PostType.Article) {
objectType = 'Article';
}
const object = {
type: objectType,
name: post.title,
content: post.content,
url: post.url,
attributedTo: actor,
image: post.featureImageUrl ?? '',
published: post.publishedAt,
preview: {
type: '',
content: post.excerpt
},
// These are used in the app, but are not part of the ObjectProperties type
id: post.id,
replyCount: post.replyCount,
liked: post.likedByMe,
reposted: post.repostedByMe,
repostCount: post.repostCount,
// These are not used but needed to comply with the ObjectProperties type
'@context': ''
};
return {
id: post.id,
type: activityType,
// If the post has been reposted, then the actor should be the sharer
// (the object of the repost is still attributed to the original author)
actor: repostedBy !== null ? repostedBy : actor,
object,
// These are not used but needed to comply with the Activity type
'@context': '',
to: ''
};
}

View file

@ -0,0 +1,109 @@
import {Post, PostType} from '../../../src/api/activitypub';
import {mapPostToActivity} from '../../../src/utils/posts';
describe('mapPostToActivity', function () {
let post: Post;
beforeEach(function () {
post = {
id: '123',
type: PostType.Article,
title: 'Test Post',
excerpt: 'Test Excerpt',
content: 'Test Content',
url: 'https://example.com/posts/123',
featureImageUrl: 'https://example.com/posts/123/feature.jpg',
publishedAt: '2024-01-01T00:00:00Z',
likeCount: 2,
likedByMe: true,
replyCount: 3,
readingTimeMinutes: 4,
attachments: [],
author: {
id: 'https://example.com/users/123',
handle: '@testuser@example.com',
avatarUrl: 'https://example.com/users/123/avatar.jpg',
name: 'Test User',
url: 'https://example.com/users/123'
},
repostCount: 5,
repostedByMe: false,
repostedBy: null
};
});
test('it sets the correct activity type', function () {
expect(
mapPostToActivity(post).type
).toBe('Create');
expect(
mapPostToActivity({
...post,
repostedBy: {
id: 'https://example.com/users/456',
handle: '@testuser2@example.com',
avatarUrl: 'https://example.com/users/456/avatar.jpg',
name: 'Test User 2',
url: 'https://example.com/users/456'
}
}).type
).toBe('Announce');
});
test('it sets the correct actor', function () {
let actor = mapPostToActivity(post).actor;
expect(actor.id).toBe('https://example.com/users/123');
expect(actor.icon.url).toBe('https://example.com/users/123/avatar.jpg');
expect(actor.name).toBe('Test User');
expect(actor.preferredUsername).toBe('testuser');
// When the post has been reposted, the actor should be the reposter
actor = mapPostToActivity({
...post,
repostedBy: {
id: 'https://example.com/users/456',
handle: '@testuser2@example.com',
avatarUrl: 'https://example.com/users/456/avatar.jpg',
name: 'Test User 2',
url: 'https://example.com/users/456'
}
}).actor;
expect(actor.id).toBe('https://example.com/users/456');
expect(actor.icon.url).toBe('https://example.com/users/456/avatar.jpg');
expect(actor.name).toBe('Test User 2');
expect(actor.preferredUsername).toBe('testuser2');
});
test('it sets the correct object type', function () {
expect(
mapPostToActivity(post).object.type
).toBe('Article');
expect(
mapPostToActivity({
...post,
type: PostType.Note
}).object.type
).toBe('Note');
});
test('it sets the correct object', function () {
const object = mapPostToActivity(post).object;
expect(object.type).toBe('Article');
expect(object.name).toBe('Test Post');
expect(object.content).toBe('Test Content');
expect(object.url).toBe('https://example.com/posts/123');
expect(object.attributedTo.id).toBe('https://example.com/users/123');
expect(object.published).toBe('2024-01-01T00:00:00Z');
expect(object.preview.content).toBe('Test Excerpt');
expect(object.id).toBe('123');
expect(object.replyCount).toBe(3);
expect(object.liked).toBe(true);
expect(object.reposted).toBe(false);
expect(object.repostCount).toBe(5);
});
});