Added alternative layout for displaying ActivityPub articles (#20556)
ref https://linear.app/tryghost/issue/MOM-244/add-alternative-inboxfeed-design, https://linear.app/tryghost/issue/MOM-256/fix-typescript-not-being-able-to-resolve-the-type - Added a toggle for switching to an alternative layout for displaying articles in the inbox - Fixed actor icons not showing up in the inbox - Fixed Typescript not being able to resolve the type for images - Added Jest to make tests run --------- Co-authored-by: Princi Vershwal <vershwal.princi@gmail.com>
This commit is contained in:
parent
6e884b1a95
commit
a6c6114e6f
@ -36,10 +36,13 @@
|
||||
"@testing-library/react": "14.1.0",
|
||||
"@tryghost/admin-x-design-system": "0.0.0",
|
||||
"@tryghost/admin-x-framework": "0.0.0",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"jest": "29.7.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
"react-dom": "18.3.1",
|
||||
"ts-jest": "29.1.5"
|
||||
},
|
||||
"nx": {
|
||||
"targets": {
|
||||
|
@ -1,10 +1,9 @@
|
||||
// import NiceModal from '@ebay/nice-modal-react';
|
||||
// import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png';
|
||||
import React, {useState} from 'react';
|
||||
import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png';
|
||||
import React, {useEffect, useRef, 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 {Avatar, Button, ButtonGroup, Heading, List, ListItem, Page, SelectOption, 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';
|
||||
|
||||
@ -33,6 +32,8 @@ const ActivityPubComponent: React.FC = () => {
|
||||
setArticleContent(null);
|
||||
};
|
||||
|
||||
const [selectedOption, setSelectedOption] = useState<SelectOption>({label: 'Inbox', value: 'inbox'});
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState('inbox');
|
||||
|
||||
const tabs: ViewTab[] = [
|
||||
@ -40,15 +41,15 @@ const ActivityPubComponent: React.FC = () => {
|
||||
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'>
|
||||
<ul className={`order-2 col-span-6 flex flex-col pb-8 lg:order-1 ${selectedOption.value === 'inbox' ? 'lg:col-span-4' : 'lg:col-span-3'}`}>
|
||||
{activities && activities.some(activity => activity.type === 'Create' && activity.object.type === 'Article') ? (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}/>
|
||||
<ObjectContentDisplay actor={activity.actor} layout={selectedOption.value} object={activity.object}/>
|
||||
</li>
|
||||
))) : <div className='flex items-center justify-center text-center'>
|
||||
<div className='flex max-w-[32em] flex-col items-center justify-center gap-4'>
|
||||
{/* <img alt='Ghost site logos' className='w-[220px]' src={ActivityPubWelcomeImage}/> */}
|
||||
<img alt='Ghost site logos' className='w-[220px]' src={ActivityPubWelcomeImage}/>
|
||||
<Heading className='text-balance' level={2}>Welcome to ActivityPub</Heading>
|
||||
<p className='text-pretty text-grey-800'>We’re so glad to have you on board! At the moment, you can follow other Ghost sites and enjoy their content right here inside Ghost.</p>
|
||||
<p className='text-pretty text-grey-800'>You can see all of the users on the right—find your favorite ones and give them a follow.</p>
|
||||
@ -78,7 +79,7 @@ const ActivityPubComponent: React.FC = () => {
|
||||
{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}/>
|
||||
<ObjectContentDisplay actor={activity.actor} layout={selectedOption.value} object={activity.object} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@ -91,6 +92,25 @@ const ActivityPubComponent: React.FC = () => {
|
||||
<Page>
|
||||
{!articleContent ? (
|
||||
<ViewContainer
|
||||
actions={[<ButtonGroup buttons={[
|
||||
{
|
||||
icon: 'listview',
|
||||
size: 'sm',
|
||||
iconColorClass: selectedOption.value === 'inbox' ? 'text-black' : 'text-grey-500',
|
||||
onClick: () => {
|
||||
setSelectedOption({label: 'Inbox', value: 'inbox'});
|
||||
}
|
||||
|
||||
},
|
||||
{
|
||||
icon: 'cardview',
|
||||
size: 'sm',
|
||||
iconColorClass: selectedOption.value === 'feed' ? 'text-black' : 'text-grey-500',
|
||||
onClick: () => {
|
||||
setSelectedOption({label: 'Feed', value: 'feed'});
|
||||
}
|
||||
}
|
||||
]} clearBg={false} link outlineOnMobile />]}
|
||||
firstOnPage={true}
|
||||
primaryAction={{
|
||||
title: 'Follow',
|
||||
@ -102,9 +122,10 @@ const ActivityPubComponent: React.FC = () => {
|
||||
selectedTab={selectedTab}
|
||||
stickyHeader={true}
|
||||
tabs={tabs}
|
||||
toolbarBorder={false}
|
||||
type='page'
|
||||
onTabChange={setSelectedTab}
|
||||
title='ActivityPub'
|
||||
toolbarBorder={true}
|
||||
type='page'
|
||||
onTabChange={setSelectedTab}
|
||||
>
|
||||
</ViewContainer>
|
||||
|
||||
@ -117,7 +138,7 @@ const ActivityPubComponent: React.FC = () => {
|
||||
};
|
||||
|
||||
const Sidebar: React.FC<{followingCount: number, followersCount: number, updateRoute: (route: string) => void}> = ({followingCount, followersCount, updateRoute}) => (
|
||||
<div className='order-1 col-span-6 flex flex-col gap-5 lg:order-2 lg:col-span-2'>
|
||||
<div className='order-1 col-span-6 flex flex-col gap-5 lg:order-2 lg:col-span-2 lg:col-start-5'>
|
||||
<div className='rounded-xl bg-grey-50 p-6' 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'>
|
||||
@ -146,17 +167,17 @@ const Sidebar: React.FC<{followingCount: number, followersCount: number, updateR
|
||||
);
|
||||
|
||||
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 iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, ''));
|
||||
|
||||
const htmlContent = `
|
||||
<html>
|
||||
<head>
|
||||
${cssContent}
|
||||
${cssContent}
|
||||
</head>
|
||||
<body>
|
||||
<header class="gh-article-header gh-canvas">
|
||||
@ -174,20 +195,29 @@ ${image &&
|
||||
</html>
|
||||
`;
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (iframe) {
|
||||
iframe.srcdoc = htmlContent;
|
||||
}
|
||||
}, [htmlContent]);
|
||||
|
||||
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>
|
||||
<div>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={`h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]`}
|
||||
height="100%"
|
||||
id="gh-ap-article-iframe"
|
||||
title="Embedded Content"
|
||||
width="100%"
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties }> = ({actor, object}) => {
|
||||
const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties, layout: string }> = ({actor, object, layout}) => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(object.content || '', 'text/html');
|
||||
|
||||
@ -205,37 +235,75 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp
|
||||
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>
|
||||
if (layout === 'feed') {
|
||||
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-6' data-test-activity>
|
||||
|
||||
<div className='relative z-10 mb-3 flex w-full items-center gap-3'>
|
||||
<img className='w-8' src={actor.icon.url}/>
|
||||
<div>
|
||||
<p className='text-base font-bold' data-test-activity-heading>{actor.name}</p>
|
||||
<div className='*:text-base *:text-grey-900'>
|
||||
{/* <span className='truncate before:mx-1 before:content-["·"]'>{getUsername(actor)}</span> */}
|
||||
<span>{timestamp}</span>
|
||||
</div>
|
||||
</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 className='relative z-10 w-full gap-4'>
|
||||
<div className='flex flex-col'>
|
||||
|
||||
{object.image && <div className='relative mb-4'>
|
||||
<img className='h-[300px] w-full rounded object-cover' src={object.image}/>
|
||||
</div>}
|
||||
<Heading className='mb-1 leading-tight' level={4} data-test-activity-heading>{object.name}</Heading>
|
||||
<p className='mb-4 line-clamp-3 max-w-prose text-pretty text-md text-grey-900'>{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>
|
||||
</div>
|
||||
<div className='absolute -inset-x-3 -inset-y-1 z-0 rounded transition-colors group-hover/article:bg-grey-100'></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>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if (layout === 'inbox') {
|
||||
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.url}/>
|
||||
<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-1 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-pretty text-md text-grey-800'>{object.preview?.content}</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' height='140px' src={object.image} width='170px'/>
|
||||
</div>}
|
||||
</div>
|
||||
<div className='absolute -inset-x-3 -inset-y-1 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}) => {
|
||||
|
@ -4,6 +4,7 @@
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client", "jest"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
|
Loading…
Reference in New Issue
Block a user