mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-22 18:31:57 +03:00
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:
parent
962365e6ea
commit
cb2150f33c
@ -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",
|
||||
|
1928
apps/admin-x-activitypub/public/styles/reader.css
Normal file
1928
apps/admin-x-activitypub/public/styles/reader.css
Normal file
File diff suppressed because it is too large
Load Diff
@ -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,16 +8,25 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
export default App;
|
7
apps/admin-x-activitypub/src/MainContent.tsx
Normal file
7
apps/admin-x-activitypub/src/MainContent.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import ActivityPubComponent from './components/ListIndex';
|
||||
|
||||
const MainContent = () => {
|
||||
return <ActivityPubComponent />;
|
||||
};
|
||||
|
||||
export default MainContent;
|
85
apps/admin-x-activitypub/src/components/FollowSite.tsx
Normal file
85
apps/admin-x-activitypub/src/components/FollowSite.tsx
Normal 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;
|
@ -1,26 +1,260 @@
|
||||
const ListIndex = () => {
|
||||
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>
|
||||
// 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>
|
||||
</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 (
|
||||
<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'>→</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'>→</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;
|
||||
|
44
apps/admin-x-activitypub/src/components/ViewFollowers.tsx
Normal file
44
apps/admin-x-activitypub/src/components/ViewFollowers.tsx
Normal 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);
|
59
apps/admin-x-activitypub/src/components/ViewFollowing.tsx
Normal file
59
apps/admin-x-activitypub/src/components/ViewFollowing.tsx
Normal 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);
|
5911
apps/admin-x-activitypub/src/components/articleBodyStyles.ts
Normal file
5911
apps/admin-x-activitypub/src/components/articleBodyStyles.ts
Normal file
File diff suppressed because it is too large
Load Diff
11
apps/admin-x-activitypub/src/components/modals.tsx
Normal file
11
apps/admin-x-activitypub/src/components/modals.tsx
Normal 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;
|
@ -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;
|
||||
}
|
@ -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;
|
12
apps/admin-x-activitypub/src/utils/get-username.ts
Normal file
12
apps/admin-x-activitypub/src/utils/get-username.ts
Normal 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;
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
52
apps/admin-x-activitypub/test/acceptance/listIndex.test.ts
Normal file
52
apps/admin-x-activitypub/test/acceptance/listIndex.test.ts
Normal 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();
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
@ -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');
|
||||
});
|
||||
});
|
@ -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>
|
||||
|
113
apps/admin-x-framework/src/api/activitypub.ts
Normal file
113
apps/admin-x-framework/src/api/activitypub.ts
Normal 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}`
|
||||
});
|
@ -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()) {
|
||||
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
155
apps/admin-x-framework/src/test/responses/activitypub/inbox.json
Normal file
155
apps/admin-x-framework/src/test/responses/activitypub/inbox.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -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();
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user