0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-03 23:00:14 -05:00

Refactored and reorganized FeedItem and Profile (#20919)

- Moved engagement stats to a reusable component
- Moved functions from Profile to a separate file
- Fixed Following on Your Profile and moved them from
modals to tabs
This commit is contained in:
Djordje Vlaisavljevic 2024-09-05 13:07:01 +01:00 committed by GitHub
parent 426b1d4d93
commit 21fb57eabd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 262 additions and 141 deletions

View file

@ -108,7 +108,7 @@ describe('ActivityPubAPI', function () {
response:
JSONResponse({
type: 'Collection',
items: [{
orderedItems: [{
type: 'Create',
object: {
type: 'Note'
@ -138,7 +138,7 @@ describe('ActivityPubAPI', function () {
expect(actual).toEqual(expected);
});
test('Returns an array when the items key is a single object', async function () {
test('Returns an array when the orderedItems key is a single object', async function () {
const fakeFetch = Fetch({
'https://auth.api/': {
response: JSONResponse({
@ -151,7 +151,7 @@ describe('ActivityPubAPI', function () {
response:
JSONResponse({
type: 'Collection',
items: {
orderedItems: {
type: 'Create',
object: {
type: 'Note'
@ -255,7 +255,7 @@ describe('ActivityPubAPI', function () {
response:
JSONResponse({
type: 'Collection',
items: [{
orderedItems: [{
type: 'Person'
}]
})

View file

@ -44,6 +44,9 @@ export class ActivityPubAPI {
if (json === null) {
return [];
}
if ('orderedItems' in json) {
return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems];
}
if ('items' in json) {
return Array.isArray(json.items) ? json.items : [json.items];
}
@ -59,6 +62,9 @@ export class ActivityPubAPI {
if (json === null) {
return [];
}
if ('orderedItems' in json) {
return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems];
}
if ('items' in json) {
return Array.isArray(json.items) ? json.items : [json.items];
}
@ -86,7 +92,7 @@ export class ActivityPubAPI {
return [];
}
if ('orderedItems' in json) {
return json.orderedItems as Activity[];
return Array.isArray(json.orderedItems) ? json.orderedItems : [json.orderedItems];
}
return [];
}
@ -106,4 +112,9 @@ export class ActivityPubAPI {
const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl);
await this.fetchJSON(url, 'POST');
}
async getActor(url: string): Promise<Actor> {
const json = await this.fetchJSON(new URL(url));
return json as Actor;
}
}

View file

@ -57,7 +57,7 @@ const Inbox: React.FC<InboxProps> = ({}) => {
type={activity.type}
/>
{index < inboxTabActivities.length - 1 && (
<div className="my-4 h-px w-full bg-grey-200"></div>
<div className="h-px w-full bg-grey-200"></div>
)}
</li>
))}

View file

@ -1,50 +1,18 @@
import APAvatar from './global/APAvatar';
import ActivityItem from './activities/ActivityItem';
import MainNavigation from './navigation/MainNavigation';
import React, {useState} from 'react';
import {ActivityPubAPI} from '../api/activitypub';
import {Heading, NoValueLabel, Tab, TabView} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useQuery} from '@tanstack/react-query';
import getUsername from '../utils/get-username';
import {Button, Heading, List, NoValueLabel, Tab, TabView} from '@tryghost/admin-x-design-system';
import {useFollowersCountForUser, useFollowersForUser, useFollowingCountForUser, useFollowingForUser} from '../hooks/useActivityPubQueries';
interface ProfileProps {}
function useFollowersCountForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`followersCount:${handle}`],
async queryFn() {
return api.getFollowersCount();
}
});
}
function useFollowingCountForUser(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useQuery({
queryKey: [`followingCount:${handle}`],
async queryFn() {
return api.getFollowingCount();
}
});
}
const Profile: React.FC<ProfileProps> = ({}) => {
const {data: followersCount = 0} = useFollowersCountForUser('index');
const {data: followingCount = 0} = useFollowingCountForUser('index');
const {data: following = []} = useFollowingForUser('index');
const {data: followers = []} = useFollowersForUser('index');
type ProfileTab = 'posts' | 'likes' | 'following' | 'followers';
@ -55,7 +23,7 @@ const Profile: React.FC<ProfileProps> = ({}) => {
id: 'posts',
title: 'Posts',
contents: (<div><NoValueLabel icon='pen'>
You havent posted anything yet.
You haven&apos;t posted anything yet.
</NoValueLabel></div>),
counter: 240
},
@ -63,24 +31,72 @@ const Profile: React.FC<ProfileProps> = ({}) => {
id: 'likes',
title: 'Likes',
contents: (<div><NoValueLabel icon='heart'>
You havent liked anything yet.
You haven&apos;t liked anything yet.
</NoValueLabel></div>),
counter: 27
},
{
id: 'following',
title: 'Following',
contents: (<div><NoValueLabel icon='user-add'>
You havent followed anyone yet.
</NoValueLabel></div>),
contents: (
<div>
{following.length === 0 ? (
<NoValueLabel icon='user-add'>
You haven&apos;t followed anyone yet.
</NoValueLabel>
) : (
<List>
{following.map((item) => {
return (
<ActivityItem key={item.id} url={item.url}>
<APAvatar author={item} />
<div>
<div className='text-grey-600'>
<span className='mr-1 font-bold text-black'>{item.name || item.preferredUsername || 'Unknown'}</span>
<div className='text-sm'>{getUsername(item)}</div>
</div>
</div>
<Button className='ml-auto' color='grey' label='Unfollow' link={true} onClick={(e) => {
e?.preventDefault();
alert('Implement me!');
}} />
</ActivityItem>
);
})}
</List>
)}
</div>
),
counter: followingCount
},
{
id: 'followers',
title: 'Followers',
contents: (<div><NoValueLabel icon='user-add'>
Nobodys following you yet. Their loss!
</NoValueLabel></div>),
contents: (
<div>
{followers.length === 0 ? (
<NoValueLabel icon='user-add'>
Nobody&apos;s following you yet. Their loss!
</NoValueLabel>
) : (
<List>
{followers.map((item) => {
return (
<ActivityItem key={item.id} url={item.url}>
<APAvatar author={item} />
<div>
<div className='text-grey-600'>
<span className='mr-1 font-bold text-black'>{item.name || item.preferredUsername || 'Unknown'}</span>
<div className='text-sm'>{getUsername(item)}</div>
</div>
</div>
</ActivityItem>
);
})}
</List>
)}
</div>
),
counter: followersCount
}
].filter(Boolean) as Tab<ProfileTab>[];
@ -102,17 +118,6 @@ const Profile: React.FC<ProfileProps> = ({}) => {
<a className='mt-3 block text-[1.5rem] underline' href='#'>www.coolsite.com</a>
<TabView<'posts' | 'likes' | 'following' | 'followers'> containerClassName='mt-6' selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
</div>
{/* <div className='grid grid-cols-2 gap-4'>
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/profile/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('/profile/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>
</div>
</>

View file

@ -126,6 +126,60 @@ function renderInboxAttachment(object: ObjectProperties) {
}
}
const FeedItemStats: React.FC<{
isLiked: boolean;
likeCount: number;
commentCount: number;
onLikeClick: () => void;
onCommentClick: () => void;
}> = ({isLiked: initialLikedState, likeCount, commentCount, onLikeClick, onCommentClick}) => {
const [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(initialLikedState);
const handleLikeClick = () => {
setIsClicked(true);
setIsLiked(!isLiked);
// Call the requested `onLikeClick`
onLikeClick();
setTimeout(() => setIsClicked(false), 300);
};
return (<div className='flex gap-5'>
<div className='mt-3 flex gap-1'>
<Button
className={`self-start text-grey-900 transition-all hover:opacity-70 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`}
hideLabel={true}
icon='heart'
id='like'
size='md'
unstyled={true}
onClick={(e?: React.MouseEvent<HTMLElement>) => {
e?.stopPropagation();
handleLikeClick();
}}
/>
{isLiked && <span className={`text-grey-900`}>{likeCount}</span>}
</div>
<div className='mt-3 flex gap-1'>
<Button
className={`self-start text-grey-900`}
hideLabel={true}
icon='comment'
id='comment'
size='md'
unstyled={true}
onClick={(e?: React.MouseEvent<HTMLElement>) => {
e?.stopPropagation();
onCommentClick();
}}
/>
<span className={`text-grey-900`}>{commentCount}</span>
</div>
</div>);
};
interface FeedItemProps {
actor: ActorProperties;
object: ObjectProperties;
@ -140,14 +194,10 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, last})
const date = new Date(object?.published ?? new Date());
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
const isLiked = false;
const onLikeClick = () => {
// Do API req or smth
// Don't need to know about setting timeouts or anything like that
};
let author = actor;
@ -159,15 +209,13 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, last})
return (
<>
{object && (
<div className={`group/article relative cursor-pointer pt-5`}>
<div className={`group/article relative cursor-pointer pt-6`}>
{(type === 'Announce' && object.type === 'Note') && <div className='z-10 mb-2 flex items-center gap-3 text-grey-700'>
<div className='z-10 flex w-10 justify-end'><Icon colorClass='text-grey-700' name='reload' size={'sm'}></Icon></div>
<span className='z-10'>{actor.name} reposted</span>
</div>}
<div className={`border-1 z-10 -my-1 grid grid-cols-[auto_1fr] grid-rows-[auto_1fr] gap-x-3 gap-y-2 border-b-grey-200 pb-4`} data-test-activity>
<div className='relative z-10 pt-[3px]'>
<APAvatar author={author}/>
</div>
<div className={`border-1 z-10 -my-1 grid grid-cols-[auto_1fr] grid-rows-[auto_1fr] gap-x-3 gap-y-2 border-b-grey-200 pb-6`} data-test-activity>
<APAvatar author={author}/>
{/* <div className='border-1 z-10 -mt-1 flex w-full flex-col items-start justify-between border-b border-b-grey-200 pb-4' data-test-activity> */}
<div className='relative z-10 flex w-full flex-col overflow-visible text-[1.5rem]'>
<div className='flex'>
@ -183,10 +231,13 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, last})
{object.name && <Heading className='mb-1 leading-tight' level={4} data-test-activity-heading>{object.name}</Heading>}
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>
{renderFeedAttachment(object, layout)}
<div className='mt-3 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>
<FeedItemStats
commentCount={2}
isLiked={isLiked}
likeCount={1}
onCommentClick={onLikeClick}
onLikeClick={onLikeClick}
/>
</div>
</div>
{/* </div> */}
@ -225,14 +276,13 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, last})
{object.name && <Heading className='mb-1 leading-tight' level={4} data-test-activity-heading>{object.name}</Heading>}
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.6rem] text-grey-900'></div>
{renderFeedAttachment(object, layout)}
{/* <div className='mt-3 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 className='mt-3 flex gap-1'>
<Button className={`self-start text-grey-900`} hideLabel={true} icon='comment' id='like' size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-900`}>2</span>
</div>
<FeedItemStats
commentCount={2}
isLiked={isLiked}
likeCount={1}
onCommentClick={onLikeClick}
onLikeClick={onLikeClick}
/>
</div>
</div>
{/* </div> */}
@ -273,10 +323,13 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, last})
{object.name && <Heading className='mb-1 leading-tight' level={4} data-test-activity-heading>{object.name}</Heading>}
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>
{renderFeedAttachment(object, layout)}
<div className='mt-3 flex gap-1'>
<Button className={`self-start text-grey-900`} hideLabel={true} icon='comment' id='like' size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-900`}>1</span>
</div>
<FeedItemStats
commentCount={2}
isLiked={isLiked}
likeCount={1}
onCommentClick={onLikeClick}
onLikeClick={onLikeClick}
/>
</div>
</div>
{/* </div> */}
@ -307,6 +360,13 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, last})
</div>
{renderInboxAttachment(object)}
</div>
<FeedItemStats
commentCount={2}
isLiked={isLiked}
likeCount={1}
onCommentClick={onLikeClick}
onLikeClick={onLikeClick}
/>
</div>
</div>
</div>

View file

@ -47,7 +47,7 @@ const APAvatar: React.FC<APAvatarProps> = ({author, size, badge}) => {
return (
<>
{author && author.icon?.url ? (
<div className='relative'>
<a className='relative z-10 pt-[3px] transition-opacity hover:opacity-80' href={author.url} rel='noopener noreferrer' target='_blank'>
<img
className={imageClass}
src={author.icon.url}
@ -61,7 +61,7 @@ const APAvatar: React.FC<APAvatarProps> = ({author, size, badge}) => {
/>
</div>
)}
</div>
</a>
) : (
<div className={containerClass}>
<Icon

View file

@ -1,10 +1,11 @@
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import getUsername from '../../utils/get-username';
import {ActivityPubAPI} from '../../api/activitypub';
import {Avatar, Button, List, ListItem, Modal} from '@tryghost/admin-x-design-system';
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useMutation, useQuery} from '@tanstack/react-query';
import {useQuery} from '@tanstack/react-query';
function useFollowersForUser(handle: string) {
const site = useBrowseSite();
@ -18,39 +19,21 @@ function useFollowersForUser(handle: string) {
return useQuery({
queryKey: [`followers:${handle}`],
async queryFn() {
return api.getFollowers();
}
});
}
function useFollow(handle: string) {
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
const api = new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
return useMutation({
async mutationFn(username: string) {
return api.follow(username);
const followerUrls = await api.getFollowers();
const followerActors = await Promise.all(followerUrls.map(url => api.getActor(url)));
return followerActors;
}
});
}
const ViewFollowersModal: React.FC<RoutingModalProps> = ({}) => {
const {updateRoute} = useRouting();
// const modal = NiceModal.useModal();
const mutation = useFollow('index');
const {data: items = []} = useFollowersForUser('index');
const {data: followers = [], isLoading} = useFollowersForUser('index');
const followers = Array.isArray(items) ? items : [items];
return (
<Modal
afterClose={() => {
mutation.reset();
updateRoute('profile');
}}
cancelLabel=''
@ -61,11 +44,22 @@ const ViewFollowersModal: React.FC<RoutingModalProps> = ({}) => {
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(getUsername(item))} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsername(item)} id='list-item' title={item.name}></ListItem>
))}
</List>
{isLoading ? (
<p>Loading followers...</p>
) : (
<List>
{followers.map(item => (
<ListItem
key={item.id}
action={<Button color='grey' label='Remove' link={true} />}
avatar={<Avatar image={item.icon} size='sm' />}
detail={getUsername(item)}
id='list-item'
title={item.name || getUsername(item)}
/>
))}
</List>
)}
</div>
</Modal>
);

View file

@ -26,9 +26,8 @@ function useFollowingForUser(handle: string) {
const ViewFollowingModal: React.FC<RoutingModalProps> = ({}) => {
const {updateRoute} = useRouting();
const {data: items = []} = useFollowingForUser('index');
const {data: following = []} = useFollowingForUser('index');
const following = Array.isArray(items) ? items : [items];
return (
<Modal
afterClose={() => {
@ -44,25 +43,16 @@ const ViewFollowingModal: React.FC<RoutingModalProps> = ({}) => {
<div className='mt-3 flex flex-col gap-4 pb-12'>
<List>
{following.map(item => (
<ListItem action={<Button color='grey' label='Unfollow' link={true} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsername(item)} id='list-item' title={item.name}></ListItem>
<ListItem
key={item.id} // Add a key prop
action={<Button color='grey' label='Unfollow' link={true} />}
avatar={<Avatar image={item.icon} size='sm' />}
detail={getUsername(item)}
id='list-item'
title={item.name}
/>
))}
</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>
);

View file

@ -0,0 +1,61 @@
import {ActivityPubAPI} from '../api/activitypub';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useQuery} from '@tanstack/react-query';
const useSiteUrl = () => {
const site = useBrowseSite();
return site.data?.site?.url ?? window.location.origin;
};
function createActivityPubAPI(handle: string, siteUrl: string) {
return new ActivityPubAPI(
new URL(siteUrl),
new URL('/ghost/api/admin/identities/', window.location.origin),
handle
);
}
export function useFollowersCountForUser(handle: string) {
const siteUrl = useSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return useQuery({
queryKey: [`followersCount:${handle}`],
async queryFn() {
return api.getFollowersCount();
}
});
}
export function useFollowingCountForUser(handle: string) {
const siteUrl = useSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return useQuery({
queryKey: [`followingCount:${handle}`],
async queryFn() {
return api.getFollowingCount();
}
});
}
export function useFollowingForUser(handle: string) {
const siteUrl = useSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return useQuery({
queryKey: [`following:${handle}`],
async queryFn() {
return api.getFollowing();
}
});
}
export function useFollowersForUser(handle: string) {
const siteUrl = useSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return useQuery({
queryKey: [`followers:${handle}`],
async queryFn() {
return api.getFollowers();
}
});
}