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:
parent
4f8678f526
commit
ec327badf9
14 changed files with 743 additions and 176 deletions
|
@ -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']
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@tryghost/admin-x-activitypub",
|
||||
"version": "0.3.63",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
122
apps/admin-x-activitypub/src/utils/posts.ts
Normal file
122
apps/admin-x-activitypub/src/utils/posts.ts
Normal 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: ''
|
||||
};
|
||||
}
|
109
apps/admin-x-activitypub/test/unit/utils/posts.test.ts
Normal file
109
apps/admin-x-activitypub/test/unit/utils/posts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue