Added Ghost2Ghost ActivityPub feature that uses mock API (#20411)

ref https://linear.app/tryghost/issue/MOM-108/ap-phase-two

Added a WIP version of the Ghost-to-Ghost ActivityPub feature behind the feature flag. Enabling it will add a new item to the main sidebar nav that lets you interact with our ActivityPub mock API in the following ways:
- Shows you the list of sites you follow
- Shows you the list of sites that follow you
- Shows you the articles published by sites you follow
- Shows you activities (who followed you or liked your article)
- Shows your liked articles

Mock API can be easily updated to simulate working with different types of data and interactions.
This commit is contained in:
Fabien 'egg' O'Carroll 2024-06-19 18:46:02 +07:00 committed by GitHub
parent 962365e6ea
commit cb2150f33c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 8759 additions and 52 deletions

View File

@ -25,13 +25,14 @@
"lint": "yarn run lint:code && yarn run lint:test",
"lint:code": "eslint --ext .js,.ts,.cjs,.tsx --cache src",
"lint:test": "eslint -c test/.eslintrc.cjs --ext .js,.ts,.cjs,.tsx --cache test",
"test:unit": "vitest run",
"test:unit": "yarn nx build && vitest run",
"test:acceptance": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' VITE_TEST=true playwright test",
"test:acceptance:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=100 yarn test:acceptance --headed",
"test:acceptance:full": "ALL_BROWSERS=1 yarn test:acceptance",
"preview": "vite preview"
},
"devDependencies": {
"@playwright/test": "1.38.1",
"@testing-library/react": "14.1.0",
"@tryghost/admin-x-design-system": "0.0.0",
"@tryghost/admin-x-framework": "0.0.0",

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import ListIndex from './components/ListIndex';
import MainContent from './MainContent';
import {DesignSystemApp, DesignSystemAppProps} from '@tryghost/admin-x-design-system';
import {FrameworkProvider, TopLevelFrameworkProps} from '@tryghost/admin-x-framework';
import {RoutingProvider} from '@tryghost/admin-x-framework/routing';
@ -8,12 +8,21 @@ interface AppProps {
designSystem: DesignSystemAppProps;
}
const modals = {
paths: {
'follow-site': 'FollowSite',
'view-following': 'ViewFollowing',
'view-followers': 'ViewFollowers'
},
load: async () => import('./components/modals')
};
const App: React.FC<AppProps> = ({framework, designSystem}) => {
return (
<FrameworkProvider {...framework}>
<RoutingProvider basePath='activitypub'>
<RoutingProvider basePath='activitypub' modals={modals}>
<DesignSystemApp className='admin-x-activitypub' {...designSystem}>
<ListIndex />
<MainContent />
</DesignSystemApp>
</RoutingProvider>
</FrameworkProvider>

View File

@ -0,0 +1,7 @@
import ActivityPubComponent from './components/ListIndex';
const MainContent = () => {
return <ActivityPubComponent />;
};
export default MainContent;

View File

@ -0,0 +1,85 @@
import NiceModal from '@ebay/nice-modal-react';
import {Modal, TextField, showToast} from '@tryghost/admin-x-design-system';
import {useFollow} from '@tryghost/admin-x-framework/api/activitypub';
import {useQueryClient} from '@tryghost/admin-x-framework';
import {useRouting} from '@tryghost/admin-x-framework/routing';
import {useState} from 'react';
// const sleep = (ms: number) => (
// new Promise((resolve) => {
// setTimeout(resolve, ms);
// })
// );
const FollowSite = NiceModal.create(() => {
const {updateRoute} = useRouting();
const modal = NiceModal.useModal();
const mutation = useFollow();
const client = useQueryClient();
// mutation.isPending
// mutation.isError
// mutation.isSuccess
// mutation.mutate({username: '@index@site.com'})
// mutation.reset();
// State to manage the text field value
const [profileName, setProfileName] = useState('');
// const [success, setSuccess] = useState(false);
const [errorMessage, setError] = useState(null);
const handleFollow = async () => {
try {
// Perform the mutation
await mutation.mutateAsync({username: profileName});
// If successful, set the success state to true
// setSuccess(true);
showToast({
message: 'Site followed',
type: 'success'
});
// // Because we don't return the new follower data from the API, we need to wait a bit to let it process and then update the query.
// // This is a dirty hack and should be replaced with a better solution.
// await sleep(2000);
modal.remove();
// Refetch the following data.
// At this point it might not be updated yet, but it will be eventually.
await client.refetchQueries({queryKey: ['FollowingResponseData'], type: 'active'});
updateRoute('');
} catch (error) {
// If there's an error, set the error state
setError(errorMessage);
}
};
return (
<Modal
afterClose={() => {
mutation.reset();
updateRoute('');
}}
cancelLabel='Cancel'
okLabel='Follow'
size='sm'
title='Follow a Ghost site'
onOk={handleFollow}
>
<div className='mt-3 flex flex-col gap-4'>
<TextField
autoFocus={true}
error={Boolean(errorMessage)}
hint={errorMessage}
placeholder='@username@hostname'
title='Profile name'
value={profileName}
data-test-new-follower
onChange={e => setProfileName(e.target.value)}
/>
</div>
</Modal>
);
});
export default FollowSite;

View File

@ -1,26 +1,260 @@
const ListIndex = () => {
// import NiceModal from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import articleBodyStyles from './articleBodyStyles';
import getUsername from '../utils/get-username';
import {ActorProperties, ObjectProperties, useBrowseFollowersForUser, useBrowseFollowingForUser, useBrowseInboxForUser} from '@tryghost/admin-x-framework/api/activitypub';
import {Avatar, Button, Heading, List, ListItem, Page, SettingValue, ViewContainer, ViewTab} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useRouting} from '@tryghost/admin-x-framework/routing';
interface ViewArticleProps {
object: ObjectProperties,
onBackToList: () => void;
}
const ActivityPubComponent: React.FC = () => {
const {updateRoute} = useRouting();
// TODO: Replace with actual user ID
const {data: {orderedItems: activities = []} = {}} = useBrowseInboxForUser('index');
const {data: {totalItems: followingCount = 0} = {}} = useBrowseFollowingForUser('index');
const {data: {totalItems: followersCount = 0} = {}} = useBrowseFollowersForUser('index');
const [articleContent, setArticleContent] = useState<ObjectProperties | null>(null);
const [, setArticleActor] = useState<ActorProperties | null>(null);
const handleViewContent = (object: ObjectProperties, actor: ActorProperties) => {
setArticleContent(object);
setArticleActor(actor);
};
const handleBackToList = () => {
setArticleContent(null);
};
const [selectedTab, setSelectedTab] = useState('inbox');
const tabs: ViewTab[] = [
{
id: 'inbox',
title: 'Inbox',
contents: <div className='grid grid-cols-6 items-start gap-8'>
<ul className='order-2 col-span-6 flex flex-col lg:order-1 lg:col-span-4'>
{activities && activities.slice().reverse().map(activity => (
activity.type === 'Create' && activity.object.type === 'Article' &&
<li key={activity.id} data-test-view-article onClick={() => handleViewContent(activity.object, activity.actor)}>
<ObjectContentDisplay actor={activity.actor} object={activity.object}/>
</li>
))}
</ul>
<Sidebar followersCount={followersCount} followingCount={followingCount} updateRoute={updateRoute} />
</div>
},
{
id: 'activity',
title: 'Activity',
contents: <div className='grid grid-cols-6 items-start gap-8'><List className='col-span-4'>
{activities && activities.slice().reverse().map(activity => (
activity.type === 'Like' && <ListItem avatar={<Avatar image={activity.actor.icon} size='sm' />} id='list-item' title={<div><span className='font-medium'>{activity.actor.name}</span><span className='text-grey-800'> liked your post </span><span className='font-medium'>{activity.object.name}</span></div>}></ListItem>
))}
</List>
<Sidebar followersCount={followersCount} followingCount={followingCount} updateRoute={updateRoute} />
</div>
},
{
id: 'likes',
title: 'Likes',
contents: <div className='grid grid-cols-6 items-start gap-8'>
<ul className='order-2 col-span-6 flex flex-col lg:order-1 lg:col-span-4'>
{activities && activities.slice().reverse().map(activity => (
activity.type === 'Create' && activity.object.type === 'Article' &&
<li key={activity.id} data-test-view-article onClick={() => handleViewContent(activity.object, activity.actor)}>
<ObjectContentDisplay actor={activity.actor} object={activity.object}/>
</li>
))}
</ul>
<Sidebar followersCount={followersCount} followingCount={followingCount} updateRoute={updateRoute} />
</div>
}
];
return (
<div className='mx-auto my-0 w-full max-w-3xl p-12'>
<h1 className='mb-6 text-black'>ActivityPub Demo</h1>
<div className='flex flex-col'>
<div className='mb-4 flex flex-col'>
<h2 className='mb-2 text-2xl text-black'>This is a post title</h2>
<p className='mb-2 text-lg text-grey-950'>This is some very short post content</p>
<p className='text-md text-grey-700'>Publish McPublisher</p>
</div>
<div className='mb-4 flex flex-col'>
<h2 className='mb-2 text-2xl text-black'>This is a post title</h2>
<p className='mb-2 text-lg text-grey-950'>This is some very short post content</p>
<p className='text-md text-grey-700'>Publish McPublisher</p>
</div>
<div className='mb-4 flex flex-col'>
<h2 className='mb-2 text-2xl text-black'>This is a post title</h2>
<p className='mb-2 text-lg text-grey-950'>This is some very short post content</p>
<p className='text-md text-grey-700'>Publish McPublisher</p>
</div>
</div>
</div>
<Page>
{!articleContent ? (
<ViewContainer
firstOnPage={true}
primaryAction={{
title: 'Follow',
onClick: () => {
updateRoute('follow-site');
},
icon: 'add'
}}
selectedTab={selectedTab}
stickyHeader={true}
tabs={tabs}
toolbarBorder={false}
type='page'
onTabChange={setSelectedTab}
>
</ViewContainer>
) : (
<ViewArticle object={articleContent} onBackToList={handleBackToList} />
)}
</Page>
);
};
export default ListIndex;
const Sidebar: React.FC<{followingCount: number, followersCount: number, updateRoute: (route: string) => void}> = ({followingCount, followersCount, updateRoute}) => (
<div className='order-1 col-span-6 rounded-xl bg-grey-50 p-6 lg:order-2 lg:col-span-2' id="ap-sidebar">
<div className='mb-4 border-b border-b-grey-200 pb-4'><SettingValue key={'your-username'} heading={'Your username'} value={'@index@localplaceholder.com'}/></div>
<div className='grid grid-cols-2 gap-4'>
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/view-following')}>
<span className='text-3xl font-bold leading-none' data-test-following-count>{followingCount}</span>
<span className='text-base leading-none text-grey-800 group-hover/stat:text-grey-900' data-test-following-modal>Following<span className='ml-1 opacity-0 transition-opacity group-hover/stat:opacity-100'>&rarr;</span></span>
</div>
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/view-followers')}>
<span className='text-3xl font-bold leading-none' data-test-following-count>{followersCount}</span>
<span className='text-base leading-none text-grey-800 group-hover/stat:text-grey-900' data-test-followers-modal>Followers<span className='ml-1 opacity-0 transition-opacity group-hover/stat:opacity-100'>&rarr;</span></span>
</div>
</div>
</div>
);
const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => {
// const dangerouslySetInnerHTML = {__html: html};
// const cssFile = '../index.css';
const site = useBrowseSite();
const siteData = site.data?.site;
const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, ''));
const htmlContent = `
<html>
<head>
${cssContent}
</head>
<body>
<header class="gh-article-header gh-canvas">
<h1 class="gh-article-title is-title" data-test-article-heading>${heading}</h1>
${image &&
`<figure class="gh-article-image">
<img src="${image}" alt="${heading}" />
</figure>`
}
</header>
<div class="gh-content gh-canvas is-body">
${html}
</div>
</body>
</html>
`;
return (
<iframe
className='h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]'
height="100%"
id="gh-ap-article-iframe"
srcDoc={htmlContent}
title="Embedded Content"
width="100%"
>
</iframe>
);
};
const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties }> = ({actor, object}) => {
const parser = new DOMParser();
const doc = parser.parseFromString(object.content || '', 'text/html');
const plainTextContent = doc.body.textContent;
const timestamp =
new Date(object?.published ?? new Date()).toLocaleDateString('default', {year: 'numeric', month: 'short', day: '2-digit'}) + ', ' + new Date(object?.published ?? new Date()).toLocaleTimeString('default', {hour: '2-digit', minute: '2-digit'});
const [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(false);
const handleLikeClick = (event: React.MouseEvent<HTMLElement> | undefined) => {
event?.stopPropagation();
setIsClicked(true);
setIsLiked(!isLiked);
setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms
};
return (
<>
{object && (
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-5' data-test-activity>
<div className='relative z-10 mb-3 grid w-full grid-cols-[20px_auto_1fr_auto] items-center gap-2 text-base'>
<img className='w-5' src={actor.icon}/>
<span className='truncate font-semibold'>{actor.name}</span>
<span className='truncate text-grey-800'>{getUsername(actor)}</span>
<span className='ml-auto text-right text-grey-800'>{timestamp}</span>
</div>
<div className='relative z-10 grid w-full grid-cols-[auto_170px] gap-4'>
<div className='flex flex-col'>
<div className='flex w-full justify-between gap-4'>
<Heading className='mb-2 line-clamp-2 leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>
</div>
<p className='mb-6 line-clamp-2 max-w-prose text-md text-grey-800'>{plainTextContent}</p>
<div className='flex gap-2'>
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id="like" size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
</div>
</div>
{object.image && <div className='relative min-w-[33%] grow'>
<img className='absolute h-full w-full rounded object-cover' src={object.image}/>
</div>}
</div>
<div className='absolute -inset-x-3 inset-y-0 z-0 rounded transition-colors group-hover/article:bg-grey-50'></div>
{/* <div className='absolute inset-0 z-0 rounded from-white to-grey-50 transition-colors group-hover/article:bg-gradient-to-r'></div> */}
</div>
)}
</>
);
};
const ViewArticle: React.FC<ViewArticleProps> = ({object, onBackToList}) => {
const {updateRoute} = useRouting();
const [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(false);
const handleLikeClick = (event: React.MouseEvent<HTMLElement> | undefined) => {
event?.stopPropagation();
setIsClicked(true);
setIsLiked(!isLiked);
setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms
};
return (
<Page>
<ViewContainer
toolbarBorder={false}
type='page'
>
<div className='grid grid-cols-[1fr_minmax(320px,_700px)_1fr] gap-x-6 pb-4'>
<div>
<Button icon='chevron-left' iconSize='xs' label='Inbox' data-test-back-button onClick={onBackToList}/>
</div>
<div className='flex items-center justify-between'>
</div>
<div className='flex justify-end'>
<div className='flex flex-row-reverse items-center gap-3'>
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id="like" size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
</div>
<Button hideLabel={true} icon='arrow-top-right' iconSize='xs' label='Visit site' onClick={() => updateRoute('/')}/>
</div>
</div>
<div className='mx-[-4.8rem] mb-[-4.8rem] w-auto'>
<ArticleBody heading={object.name} html={object.content} image={object?.image}/>
</div>
</ViewContainer>
</Page>
);
};
export default ActivityPubComponent;

View File

@ -0,0 +1,44 @@
import {} from '@tryghost/admin-x-framework/api/activitypub';
import NiceModal from '@ebay/nice-modal-react';
import getUsernameFromFollowing from '../utils/get-username-from-following';
import {Avatar, Button, List, ListItem, Modal} from '@tryghost/admin-x-design-system';
import {FollowingResponseData, useBrowseFollowersForUser, useUnfollow} from '@tryghost/admin-x-framework/api/activitypub';
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
interface ViewFollowersModalProps {
following: FollowingResponseData[],
animate?: boolean
}
const ViewFollowersModal: React.FC<RoutingModalProps & ViewFollowersModalProps> = ({}) => {
const {updateRoute} = useRouting();
// const modal = NiceModal.useModal();
const mutation = useUnfollow();
const {data: {orderedItems: followers = []} = {}} = useBrowseFollowersForUser('inbox');
return (
<Modal
afterClose={() => {
mutation.reset();
updateRoute('');
}}
cancelLabel=''
footer={false}
okLabel=''
size='md'
title='Followers'
topRightContent='close'
>
<div className='mt-3 flex flex-col gap-4 pb-12'>
<List>
{followers.map(item => (
<ListItem action={<Button color='grey' label='Follow back' link={true} onClick={() => mutation.mutate({username: item.username})} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsernameFromFollowing(item)} id='list-item' title={item.name}></ListItem>
))}
</List>
</div>
</Modal>
);
};
export default NiceModal.create(ViewFollowersModal);

View File

@ -0,0 +1,59 @@
import {} from '@tryghost/admin-x-framework/api/activitypub';
import NiceModal from '@ebay/nice-modal-react';
import getUsernameFromFollowing from '../utils/get-username-from-following';
import {Avatar, Button, List, ListItem, Modal} from '@tryghost/admin-x-design-system';
import {FollowingResponseData, useBrowseFollowingForUser, useUnfollow} from '@tryghost/admin-x-framework/api/activitypub';
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
interface ViewFollowingModalProps {
following: FollowingResponseData[],
animate?: boolean
}
const ViewFollowingModal: React.FC<RoutingModalProps & ViewFollowingModalProps> = ({}) => {
const {updateRoute} = useRouting();
const mutation = useUnfollow();
const {data: {orderedItems: following = []} = {}} = useBrowseFollowingForUser('inbox');
return (
<Modal
afterClose={() => {
mutation.reset();
updateRoute('');
}}
cancelLabel=''
footer={false}
okLabel=''
size='md'
title='Following'
topRightContent='close'
>
<div className='mt-3 flex flex-col gap-4 pb-12'>
<List>
{following.map(item => (
<ListItem action={<Button color='grey' label='Unfollow' link={true} onClick={() => mutation.mutate({username: getUsernameFromFollowing(item)})} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsernameFromFollowing(item)} id='list-item' title={item.name}></ListItem>
))}
</List>
{/* <Table>
<TableRow>
<TableCell>
<div className='group flex items-center gap-3 hover:cursor-pointer'>
<div className={`flex grow flex-col`}>
<div className="mb-0.5 flex items-center gap-3">
<img className='w-5' src='https://www.platformer.news/content/images/size/w256h256/2024/05/Logomark_Blue_800px.png'/>
<span className='line-clamp-1 font-medium'>Platformer Platformer Platformer Platformer Platformer</span>
<span className='line-clamp-1'>@index@platformerplatformerplatformerplatformer.news</span>
</div>
</div>
</div>
</TableCell>
<TableCell className='w-[1%] whitespace-nowrap'><div className='mt-1 whitespace-nowrap text-right text-sm text-grey-700'>Unfollow</div></TableCell>
</TableRow>
</Table> */}
</div>
</Modal>
);
};
export default NiceModal.create(ViewFollowingModal);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,11 @@
import FollowSite from './FollowSite';
import ViewFollowers from './ViewFollowers';
import ViewFollowing from './ViewFollowing';
import {ModalComponent} from '@tryghost/admin-x-framework/routing';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const modals = {FollowSite, ViewFollowing, ViewFollowers} satisfies {[key: string]: ModalComponent<any>};
export default modals;
export type ModalName = keyof typeof modals;

View File

@ -1 +1,25 @@
@import '@tryghost/admin-x-design-system/styles.css';
.admin-x-base.admin-x-activitypub {
animation-name: none;
}
@keyframes bump {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.bump {
animation: bump 0.3s ease-in-out;
}
.ap-red-heart path {
fill: #F50B23;
}

View File

@ -0,0 +1,12 @@
function getUsernameFromFollowing(followItem: {username: string; id: string|null;}) {
if (!followItem.username || !followItem.id) {
return '@unknown@unknown';
}
try {
return `@${followItem.username}@${(new URL(followItem.id)).hostname}`;
} catch (err) {
return '@unknown@unknown';
}
}
export default getUsernameFromFollowing;

View File

@ -0,0 +1,12 @@
function getUsername(actor: {preferredUsername: string; id: string|null;}) {
if (!actor.preferredUsername || !actor.id) {
return '@unknown@unknown';
}
try {
return `@${actor.preferredUsername}@${(new URL(actor.id)).hostname}`;
} catch (err) {
return '@unknown@unknown';
}
}
export default getUsername;

View File

@ -5,6 +5,6 @@ test.describe('Demo', async () => {
test('Renders the list page', async ({page}) => {
await page.goto('/');
await expect(page.locator('body')).toContainText('ActivityPub Demo');
await expect(page.locator('body')).toContainText('ActivityPub Inbox');
});
});

View File

@ -0,0 +1,52 @@
import {expect, test} from '@playwright/test';
import {mockApi, responseFixtures} from '@tryghost/admin-x-framework/test/acceptance';
test.describe('ListIndex', async () => {
test('Renders the list page', async ({page}) => {
const userId = 'index';
await mockApi({
page,
requests: {
useBrowseInboxForUser: {method: 'GET', path: `/inbox/${userId}`, response: responseFixtures.activitypubInbox},
useBrowseFollowingForUser: {method: 'GET', path: `/following/${userId}`, response: responseFixtures.activitypubFollowing}
},
options: {useActivityPub: true}
});
// Printing browser consol logs
page.on('console', (msg) => {
console.log(`Browser console log: ${msg.type()}: ${msg.text()}`); /* eslint-disable-line no-console */
});
await page.goto('/');
await expect(page.locator('body')).toContainText('ActivityPub Inbox');
// following list
const followingUser = await page.locator('[data-test-following] > li').textContent();
await expect(followingUser).toEqual('@index@main.ghost.org');
const followingCount = await page.locator('[data-test-following-count]').textContent();
await expect(followingCount).toEqual('1');
// following button
const followingList = await page.locator('[data-test-following-modal]');
await expect(followingList).toBeVisible();
// activities
const activity = await page.locator('[data-test-activity-heading]').textContent();
await expect(activity).toEqual('Testing ActivityPub');
// click on article
const articleBtn = await page.locator('[data-test-view-article]');
await articleBtn.click();
// article is expanded
const frameLocator = page.frameLocator('#gh-ap-article-iframe');
const textElement = await frameLocator.locator('[data-test-article-heading]').innerText();
expect(textElement).toContain('Testing ActivityPub');
// go back to list
const backBtn = await page.locator('[data-test-back-button]');
await backBtn.click();
});
});

View File

@ -1,10 +0,0 @@
import ListIndex from '../../src/components/ListIndex';
import {render, screen} from '@testing-library/react';
describe('Demo', function () {
it('renders a component', async function () {
render(<ListIndex/>);
expect(screen.getAllByRole('heading')[0].textContent).toEqual('ActivityPub Demo');
});
});

View File

@ -0,0 +1,36 @@
import getUsername from '../../../src/utils/get-username';
describe('getUsername', function () {
it('returns the formatted username', async function () {
const user = {
preferredUsername: 'index',
id: 'https://www.platformer.news/'
};
const result = getUsername(user);
expect(result).toBe('@index@www.platformer.news');
});
it('returns a default username if the user object is missing data', async function () {
const user = {
preferredUsername: '',
id: ''
};
const result = getUsername(user);
expect(result).toBe('@unknown@unknown');
});
it('returns a default username if url parsing fails', async function () {
const user = {
preferredUsername: 'index',
id: 'not-a-url'
};
const result = getUsername(user);
expect(result).toBe('@unknown@unknown');
});
});

View File

@ -251,7 +251,7 @@ const ViewContainer: React.FC<ViewContainerProps> = ({
return (
<section className={mainContainerClassName}>
{(title || actions || headerContent) && toolbar}
{(title || actions || headerContent || tabs) && toolbar}
<div className={contentWrapperClassName}>
{mainContent}
</div>

View File

@ -0,0 +1,113 @@
import {createMutation, createQueryWithId} from '../utils/api/hooks';
export type FollowItem = {
id: string;
username: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[x: string]: any
};
export type ObjectProperties = {
'@context': string | (string | object)[];
type: 'Article' | 'Link';
name: string;
content: string;
url?: string | undefined;
attributedTo?: string | object[] | undefined;
image?: string;
published?: string;
preview?: {type: string, content: string};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[x: string]: any;
}
export type ActorProperties = {
'@context': string | (string | object)[];
attachment: object[];
discoverable: boolean;
featured: string;
followers: string;
following: string;
id: string | null;
image: string;
inbox: string;
manuallyApprovesFollowers: boolean;
name: string;
outbox: string;
preferredUsername: string;
publicKey: {
id: string;
owner: string;
publicKeyPem: string;
};
published: string;
summary: string;
type: 'Person';
url: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[x: string]: any;
}
export type Activity = {
'@context': string;
id: string;
type: string;
actor: ActorProperties;
object: ObjectProperties;
to: string;
}
export type InboxResponseData = {
'@context': string;
id: string;
summary: string;
type: 'OrderedCollection';
totalItems: number;
orderedItems: Activity[];
}
export type FollowingResponseData = {
'@context': string;
id: string;
summary: string;
type: string;
totalItems: number;
orderedItems: FollowItem[];
}
type FollowRequestProps = {
username: string
}
export const useFollow = createMutation<object, FollowRequestProps>({
method: 'POST',
useActivityPub: true,
path: data => `/follow/${data.username}`
});
export const useUnfollow = createMutation<object, FollowRequestProps>({
method: 'POST',
useActivityPub: true,
path: data => `/unfollow/${data.username}`
});
// This is a frontend root, not using the Ghost admin API
export const useBrowseInboxForUser = createQueryWithId<InboxResponseData>({
dataType: 'InboxResponseData',
useActivityPub: true,
path: id => `/inbox/${id}`
});
// This is a frontend root, not using the Ghost admin API
export const useBrowseFollowingForUser = createQueryWithId<FollowingResponseData>({
dataType: 'FollowingResponseData',
useActivityPub: true,
path: id => `/following/${id}`
});
// This is a frontend root, not using the Ghost admin API
export const useBrowseFollowersForUser = createQueryWithId<FollowingResponseData>({
dataType: 'FollowingResponseData',
useActivityPub: true,
path: id => `/followers/${id}`
});

View File

@ -16,6 +16,8 @@ import siteFixture from './responses/site.json';
import themesFixture from './responses/themes.json';
import tiersFixture from './responses/tiers.json';
import usersFixture from './responses/users.json';
import activitypubInboxFixture from './responses/activitypub/inbox.json';
import activitypubFollowingFixture from './responses/activitypub/following.json';
import {ActionsResponseType} from '../api/actions';
import {ConfigResponseType} from '../api/config';
@ -63,7 +65,9 @@ export const responseFixtures = {
themes: themesFixture as ThemesResponseType,
newsletters: newslettersFixture as NewslettersResponseType,
actions: actionsFixture as ActionsResponseType,
latestPost: {posts: [{id: '1', url: `${siteFixture.site.url}/test-post/`}]}
latestPost: {posts: [{id: '1', url: `${siteFixture.site.url}/test-post/`}]},
activitypubInbox: activitypubInboxFixture,
activitypubFollowing: activitypubFollowingFixture
};
const defaultLabFlags = {
@ -145,7 +149,7 @@ export const limitRequests = {
browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: responseFixtures.newsletters}
};
export async function mockApi<Requests extends Record<string, MockRequestConfig>>({page, requests}: {page: Page, requests: Requests}) {
export async function mockApi<Requests extends Record<string, MockRequestConfig>>({page, requests, options = {}}: {page: Page, requests: Requests, options?: {useActivityPub?: boolean}}) {
const lastApiRequests: {[key in keyof Requests]?: RequestRecord} = {};
const namedRequests = Object.entries(requests).reduce(
@ -153,8 +157,11 @@ export async function mockApi<Requests extends Record<string, MockRequestConfig>
[] as Array<MockRequestConfig & {name: keyof Requests}>
);
await page.route(/\/ghost\/api\/admin\//, async (route) => {
const apiPath = route.request().url().replace(/^.*\/ghost\/api\/admin/, '');
const routeRegex = options?.useActivityPub ? /\/activitypub\// : /\/ghost\/api\/admin\//;
const routeReplaceRegex = options.useActivityPub ? /^.*\/activitypub/ : /^.*\/ghost\/api\/admin/;
await page.route(routeRegex, async (route) => {
const apiPath = route.request().url().replace(routeReplaceRegex, '');
const matchingMock = namedRequests.find((request) => {
if (request.method !== route.request().method()) {

View File

@ -0,0 +1,13 @@
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://0a2e-129-222-88-174.ngrok-free.app/activitypub/following/deadbeefdeadbeefdeadbeef",
"summary": "Following collection for index",
"type": "Collection",
"totalItems": 1,
"items": [
{
"id": "https://main.ghost.org/activitypub/actor/deadbeefdeadbeefdeadbeef",
"username": "@index@main.ghost.org"
}
]
}

View File

@ -0,0 +1,155 @@
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/activitypub/inbox/index",
"summary": "Inbox for index",
"type": "OrderedCollection",
"totalItems": 2,
"orderedItems": [
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://main.ghost.org/activitypub/activity/664cf007fd27b20001a76d72",
"type": "Accept",
"actor": {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"featured": {
"@id": "http://joinmastodon.org/ns#featured",
"@type": "@id"
}
},
{
"discoverable": {
"@id": "http://joinmastodon.org/ns#discoverable",
"@type": "@id"
}
},
{
"manuallyApprovesFollowers": {
"@id": "http://joinmastodon.org/ns#manuallyApprovesFollowers",
"@type": "@id"
}
},
{
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value"
}
],
"type": "Person",
"id": "https://main.ghost.org/activitypub/actor/index",
"name": "The Main",
"preferredUsername": "index",
"summary": "The bio for the actor",
"url": "https://main.ghost.org/activitypub/actor/index",
"icon": "",
"image": "",
"published": "1970-01-01T00:00:00Z",
"manuallyApprovesFollowers": false,
"discoverable": true,
"attachment": [
{
"type": "PropertyValue",
"name": "Website",
"value": "<a href='https://main.ghost.org/activitypub/'>main.ghost.org</a>"
}
],
"following": "https://main.ghost.org/activitypub/following/index",
"followers": "https://main.ghost.org/activitypub/followers/index",
"inbox": "https://main.ghost.org/activitypub/inbox/index",
"outbox": "https://main.ghost.org/activitypub/outbox/index",
"featured": "https://main.ghost.org/activitypub/featured/index",
"publicKey": {
"id": "https://main.ghost.org/activitypub/actor/index#main-key",
"owner": "https://main.ghost.org/activitypub/actor/index",
"publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\nMIGJAoGBANRpUrwk7x7bJDddHmrYSWVw9enVPMFm5qAW7fTgoZ7x2PoJUIqy/bkqpXZ0SmZs\nsLO3UZm+yN/DqxioD8BnhhD0N8Ydv6+UniT7hE2tHvsMxQIq2jet1auSBZNFmUIWodsBxI/R\ntm+KwFBFk+P+MvVsGZ2K3Rkd4K0dv0/45dtXAgMBAAE=\n-----END RSA PUBLIC KEY-----\n"
}
},
"object": {
"id": "https://0a2e-129-222-88-174.ngrok-free.app/activitypub/activity/664cf0074daa2f8183ba6ea6",
"type": "Follow"
},
"to": "https://0a2e-129-222-88-174.ngrok-free.app/activitypub/actor/index"
},
{
"type": "Create",
"actor": {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"featured": {
"@id": "http://joinmastodon.org/ns#featured",
"@type": "@id"
}
},
{
"discoverable": {
"@id": "http://joinmastodon.org/ns#discoverable",
"@type": "@id"
}
},
{
"manuallyApprovesFollowers": {
"@id": "http://joinmastodon.org/ns#manuallyApprovesFollowers",
"@type": "@id"
}
},
{
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value"
}
],
"type": "Person",
"id": "https://main.ghost.org/activitypub/actor/index",
"name": "The Main",
"preferredUsername": "index",
"summary": "The bio for the actor",
"url": "https://main.ghost.org/activitypub/actor/index",
"icon": "",
"image": "",
"published": "1970-01-01T00:00:00Z",
"manuallyApprovesFollowers": false,
"discoverable": true,
"attachment": [
{
"type": "PropertyValue",
"name": "Website",
"value": "<a href='https://main.ghost.org/activitypub/'>main.ghost.org</a>"
}
],
"following": "https://main.ghost.org/activitypub/following/index",
"followers": "https://main.ghost.org/activitypub/followers/index",
"inbox": "https://main.ghost.org/activitypub/inbox/index",
"outbox": "https://main.ghost.org/activitypub/outbox/index",
"featured": "https://main.ghost.org/activitypub/featured/index",
"publicKey": {
"id": "https://main.ghost.org/activitypub/actor/index#main-key",
"owner": "https://main.ghost.org/activitypub/actor/index",
"publicKeyPem": "-----BEGIN RSA PUBLIC KEY-----\nMIGJAoGBANRpUrwk7x7bJDddHmrYSWVw9enVPMFm5qAW7fTgoZ7x2PoJUIqy/bkqpXZ0SmZs\nsLO3UZm+yN/DqxioD8BnhhD0N8Ydv6+UniT7hE2tHvsMxQIq2jet1auSBZNFmUIWodsBxI/R\ntm+KwFBFk+P+MvVsGZ2K3Rkd4K0dv0/45dtXAgMBAAE=\n-----END RSA PUBLIC KEY-----\n"
}
},
"object": {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Article",
"id": "https://main.ghost.org/activitypub/article/my-article/",
"name": "Testing ActivityPub",
"content": "<p> Super long test </p>",
"url": "https://main.ghost.org/my-article/",
"image": "https://main.ghost.org/content/images/2021/08/ghost-logo.png",
"published": "2024-05-09T00:00:00Z",
"attributedTo": {
"type": "Person",
"name": "The Main"
},
"preview": {
"type": "Link",
"href": "https://main.ghost.org/my-article/",
"name": "Testing ActivityPub"
}
}
}
]
}

View File

@ -111,10 +111,11 @@ export const useFetchApi = () => {
};
};
const {apiRoot} = getGhostPaths();
const {apiRoot, activityPubRoot} = getGhostPaths();
export const apiUrl = (path: string, searchParams: Record<string, string> = {}) => {
const url = new URL(`${apiRoot}${path}`, window.location.origin);
export const apiUrl = (path: string, searchParams: Record<string, string> = {}, useActivityPub: boolean = false) => {
const root = useActivityPub ? activityPubRoot : apiRoot;
const url = new URL(`${root}${path}`, window.location.origin);
url.search = new URLSearchParams(searchParams).toString();
return url.toString();
};

View File

@ -24,6 +24,7 @@ interface QueryOptions<ResponseData> {
defaultSearchParams?: Record<string, string>;
permissions?: string[];
returnData?: (originalData: unknown) => ResponseData;
useActivityPub?: boolean;
}
type QueryHookOptions<ResponseData> = UseQueryOptions<ResponseData> & {
@ -32,7 +33,7 @@ type QueryHookOptions<ResponseData> = UseQueryOptions<ResponseData> & {
};
export const createQuery = <ResponseData>(options: QueryOptions<ResponseData>) => ({searchParams, ...query}: QueryHookOptions<ResponseData> = {}): Omit<UseQueryResult<ResponseData>, 'data'> & {data: ResponseData | undefined} => {
const url = apiUrl(options.path, searchParams || options.defaultSearchParams);
const url = apiUrl(options.path, searchParams || options.defaultSearchParams, options?.useActivityPub);
const fetchApi = useFetchApi();
const handleError = useHandleError();
@ -66,7 +67,7 @@ export const createPaginatedQuery = <ResponseData extends {meta?: Meta}>(options
const paginatedSearchParams = searchParams || options.defaultSearchParams || {};
paginatedSearchParams.page = page.toString();
const url = apiUrl(options.path, paginatedSearchParams);
const url = apiUrl(options.path, paginatedSearchParams, options?.useActivityPub);
const fetchApi = useFetchApi();
const handleError = useHandleError();
@ -119,8 +120,8 @@ export const createInfiniteQuery = <ResponseData>(options: InfiniteQueryOptions<
const nextPageParams = getNextPageParams || options.defaultNextPageParams || (() => ({}));
const result = useInfiniteQuery<ResponseData>({
queryKey: [options.dataType, apiUrl(options.path, searchParams || options.defaultSearchParams)],
queryFn: ({pageParam}) => fetchApi(apiUrl(options.path, pageParam || searchParams || options.defaultSearchParams)),
queryKey: [options.dataType, apiUrl(options.path, searchParams || options.defaultSearchParams, options?.useActivityPub)],
queryFn: ({pageParam}) => fetchApi(apiUrl(options.path, pageParam || searchParams || options.defaultSearchParams, options?.useActivityPub)),
getNextPageParam: data => nextPageParams(data, searchParams || options.defaultSearchParams || {}),
...query
});
@ -161,7 +162,7 @@ const mutate = <ResponseData, Payload>({fetchApi, path, payload, searchParams, o
options: Omit<MutationOptions<ResponseData, Payload>, 'path'>
}) => {
const {defaultSearchParams, body, ...requestOptions} = options;
const url = apiUrl(path, searchParams || defaultSearchParams);
const url = apiUrl(path, searchParams || defaultSearchParams, options?.useActivityPub);
const generatedBody = payload && body?.(payload);
let requestBody: string | FormData | undefined = undefined;

View File

@ -3,6 +3,7 @@ export interface IGhostPaths {
adminRoot: string;
assetRoot: string;
apiRoot: string;
activityPubRoot: string;
}
export function getGhostPaths(): IGhostPaths {
@ -11,7 +12,8 @@ export function getGhostPaths(): IGhostPaths {
const adminRoot = `${subdir}/ghost/`;
const assetRoot = `${subdir}/ghost/assets/`;
const apiRoot = `${subdir}/ghost/api/admin`;
return {subdir, adminRoot, assetRoot, apiRoot};
const activityPubRoot = `${subdir}/activitypub`;
return {subdir, adminRoot, assetRoot, apiRoot, activityPubRoot};
}
export function downloadFile(url: string) {