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

Improved loading state of main screens in ActivityPub (#22158)

ref AP-654

- Improved the skeleton component of shadcn
- Used the skeleton component in the main five screens
- It minimizes the layout shift
This commit is contained in:
Sodbileg Gansukh 2025-02-11 16:00:01 +08:00 committed by GitHub
parent b2c08fc44a
commit c36ab82325
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 267 additions and 175 deletions

View file

@ -10,7 +10,7 @@ import Separator from './global/Separator';
import getName from '../utils/get-name';
import getUsername from '../utils/get-username';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button} from '@tryghost/shade';
import {Button, Skeleton} from '@tryghost/shade';
import {
GET_ACTIVITIES_QUERY_KEY_FEED,
GET_ACTIVITIES_QUERY_KEY_INBOX,
@ -48,7 +48,7 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = getActivitiesQuery;
const activities = (data?.pages.flatMap(page => page.data) ?? [])
const activities = (data?.pages.flatMap(page => page.data) ?? Array.from({length: 5}, (_, index) => ({id: `placeholder-${index}`, object: {}})))
// If there somehow are duplicate activities, filter them out so the list rendering doesn't break
.filter((activity, index, self) => index === self.findIndex(a => a.id === activity.id))
// Filter out replies
@ -59,7 +59,7 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
// Initialise suggested profiles
const {suggestedProfilesQuery} = useSuggestedProfilesForUser('index', 3);
const {data: suggestedData, isLoading: isLoadingSuggested} = suggestedProfilesQuery;
const suggested = suggestedData || [];
const suggested = suggestedData || Array(3).fill({actor: {}});
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
@ -93,11 +93,7 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
<MainNavigation page={layout}/>
<div className='z-0 mb-5 flex w-full flex-col'>
<div className='w-full px-8'>
{isLoading ? (
<div className='flex flex-col items-center justify-center space-y-4 text-center'>
<LoadingIndicator size='lg' />
</div>
) : activities.length > 0 ? (
{activities.length > 0 ? (
<>
<div className={`mx-auto flex min-h-[calc(100dvh_-_117px)] items-start gap-11`}>
<div className='flex w-full min-w-0 flex-col items-center'>
@ -117,6 +113,7 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
<FeedItem
actor={activity.actor}
commentCount={activity.object.replyCount ?? 0}
isLoading={isLoading}
layout={layout}
object={activity.object}
repostCount={activity.object.repostCount ?? 0}
@ -142,31 +139,27 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
<h2 className='mb-1.5 text-lg font-semibold'>This is your {layout === 'inbox' ? 'inbox' : 'feed'}</h2>
<p className='mb-6 text-gray-700'>You&apos;ll find {layout === 'inbox' ? 'long-form content' : 'short posts and updates'} from the accounts you follow here.</p>
<h2 className='mb-1 text-lg font-semibold'>You might also like</h2>
{isLoadingSuggested ? (
<LoadingIndicator size="sm" />
) : (
<ul className='grow'>
{suggested.map((profile, index) => {
const actor = profile.actor;
return (
<React.Fragment key={actor.id}>
<li key={actor.id}>
<ActivityItem
onClick={() => handleProfileClick(actor)}
>
<APAvatar author={actor} />
<div className='flex min-w-0 flex-col'>
<span className='block w-full truncate font-semibold text-black'>{getName(actor)}</span>
<span className='block w-full truncate text-sm text-gray-600'>{getUsername(actor)}</span>
</div>
</ActivityItem>
</li>
{index < suggested.length - 1 && <Separator />}
</React.Fragment>
);
})}
</ul>
)}
<ul className='grow'>
{suggested.map((profile, index) => {
const actor = profile.actor;
return (
<React.Fragment key={actor.id}>
<li key={actor.id}>
<ActivityItem
onClick={() => handleProfileClick(actor)}
>
{!isLoadingSuggested ? <APAvatar author={actor} /> : <Skeleton className='z-10 h-10 w-10' />}
<div className='flex min-w-0 flex-col'>
<span className='block w-full truncate font-semibold text-black'>{!isLoadingSuggested ? getName(actor) : <Skeleton className='w-24' />}</span>
<span className='block w-full truncate text-sm text-gray-600'>{!isLoadingSuggested ? getUsername(actor) : <Skeleton className='w-40' />}</span>
</div>
</ActivityItem>
</li>
{index < suggested.length - 1 && <Separator />}
</React.Fragment>
);
})}
</ul>
<Button className='mt-2 w-full' variant='outline' onClick={() => updateRoute('search')}>Explore &rarr;</Button>
</div>
</div>

View file

@ -1,4 +1,5 @@
import React, {useEffect, useRef} from 'react';
import {Skeleton} from '@tryghost/shade';
import NiceModal from '@ebay/nice-modal-react';
import {Activity, ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
@ -237,7 +238,7 @@ const Notifications: React.FC<NotificationsProps> = () => {
});
return groupActivities(filtered);
}) ?? []);
}) ?? Array(5).fill({actors: [{}]}));
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
@ -305,11 +306,6 @@ const Notifications: React.FC<NotificationsProps> = () => {
<>
<MainNavigation page='notifications'/>
<div className='z-0 flex w-full flex-col items-center'>
{
isLoading && (<div className='mt-8 flex flex-col items-center justify-center space-y-4 text-center'>
<LoadingIndicator size='lg' />
</div>)
}
{
isLoading === false && groupedActivities.length === 0 && (
<div className='mt-8'>
@ -320,7 +316,7 @@ const Notifications: React.FC<NotificationsProps> = () => {
)
}
{
(isLoading === false && groupedActivities.length > 0) && (
(groupedActivities.length > 0) && (
<>
<div className='my-8 flex w-full max-w-[560px] flex-col'>
{groupedActivities.map((group, index) => (
@ -329,14 +325,15 @@ const Notifications: React.FC<NotificationsProps> = () => {
className='hover:bg-gray-100'
onClick={() => handleActivityClick(group, index)}
>
<NotificationItem.Icon type={getActivityBadge(group)} />
{!isLoading ? <NotificationItem.Icon type={getActivityBadge(group)} /> : <Skeleton className='rounded-full' containerClassName='flex h-10 w-10' />}
<NotificationItem.Avatars>
<div className='flex flex-col'>
<div className='mt-0.5 flex items-center gap-1.5'>
{!openStates[group.id || `${group.type}_${index}`] && group.actors.slice(0, maxAvatars).map(actor => (
{!openStates[group.id || `${group.type}_${index}`] && group.actors.slice(0, maxAvatars).map((actor: ActorProperties) => (
<APAvatar
key={actor.id}
author={actor}
isLoading={isLoading}
size='notification'
/>
))}
@ -365,7 +362,7 @@ const Notifications: React.FC<NotificationsProps> = () => {
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${openStates[group.id || `${group.type}_${index}`] ? 'mb-2 max-h-[1384px] opacity-100' : 'max-h-0 opacity-0'}`}>
{openStates[group.id || `${group.type}_${index}`] && group.actors.length > 1 && (
<div className='flex flex-col gap-2 pt-4'>
{group.actors.map(actor => (
{group.actors.map((actor: ActorProperties) => (
<div
key={actor.id}
className='flex items-center hover:opacity-80'
@ -383,7 +380,13 @@ const Notifications: React.FC<NotificationsProps> = () => {
</NotificationItem.Avatars>
<NotificationItem.Content>
<div className='line-clamp-2 text-pretty text-black'>
<NotificationGroupDescription group={group} />
{!isLoading ?
<NotificationGroupDescription group={group} /> :
<>
<Skeleton />
<Skeleton className='w-full max-w-60' />
</>
}
</div>
{(
(group.type === ACTIVITY_TYPE.CREATE && group.object?.inReplyTo) ||

View file

@ -3,6 +3,7 @@ import React, {useEffect, useRef, useState} from 'react';
import NiceModal from '@ebay/nice-modal-react';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Heading, List, LoadingIndicator, NoValueLabel, Tab, TabView} from '@tryghost/admin-x-design-system';
import {Skeleton} from '@tryghost/shade';
import {
type AccountFollowsQueryResult,
@ -85,14 +86,37 @@ const useInfiniteScrollTab = <TData,>({useDataHook, emptyStateLabel, emptyStateI
)
);
const placeholderPosts = Array(4).fill({object: {}, actor: {}});
const LoadingState = () => (
<>
<div ref={loadMoreRef} className='h-1'></div>
{
(isLoading || isFetchingNextPage) && (
<div className='mt-6 flex flex-col items-center justify-center space-y-4 text-center'>
<LoadingIndicator size='md' />
</div>
!isLoading ?
<div className='mt-6 flex flex-col items-center justify-center space-y-4 text-center'>
<LoadingIndicator size='md' />
</div> :
<ul>
{placeholderPosts.map((activity, index) => (
<li
key={activity.id}
className=''
data-test-view-article
>
<FeedItem
actor={activity.actor}
isLoading={true}
layout='feed'
object={activity.object}
type={activity.type}
onClick={() => handleViewContent(activity, false)}
onCommentClick={() => handleViewContent(activity, true)}
/>
{index < placeholderPosts.length - 1 && <Separator />}
</li>
))}
</ul>
)
}
</>
@ -353,74 +377,73 @@ const Profile: React.FC<ProfileProps> = ({}) => {
return (
<>
<MainNavigation page='profile' />
{isLoadingAccount ? (
<div className='flex h-[calc(100vh-8rem)] items-center justify-center'>
<LoadingIndicator />
</div>
) : (
<div className='z-0 mx-auto mt-8 flex w-full max-w-[580px] flex-col items-center pb-16'>
<div className='mx-auto w-full'>
{account?.bannerImageUrl && (
<div className='h-[200px] w-full overflow-hidden rounded-lg bg-gradient-to-tr from-gray-200 to-gray-100'>
<img
alt={account?.name}
className='h-full w-full object-cover'
src={account?.bannerImageUrl}
/>
</div>
)}
<div className={`${account?.bannerImageUrl && '-mt-12'} px-4`}>
<div className='flex items-end justify-between'>
<div className='rounded-xl outline outline-4 outline-white'>
<APAvatar
author={account && {
icon: {
url: account?.avatarUrl
},
name: account?.name,
handle: account?.handle
}}
size='lg'
/>
</div>
</div>
<Heading className='mt-4' level={3}>{account?.name}</Heading>
<span className='mt-1 text-[1.5rem] text-gray-800'>
<span>{account?.handle}</span>
</span>
{(account?.bio || customFields.length > 0) && (
<div ref={contentRef} className={`ap-profile-content transition-max-height relative text-[1.5rem] duration-300 ease-in-out [&>p]:mb-3 ${isExpanded ? 'max-h-none pb-7' : 'max-h-[160px] overflow-hidden'} relative`}>
<div
dangerouslySetInnerHTML={{__html: account?.bio ?? ''}}
className='ap-profile-content mt-3 text-[1.5rem] [&>p]:mb-3'
/>
{customFields.map(customField => (
<span key={customField.name} className='mt-3 line-clamp-1 flex flex-col text-[1.5rem]'>
<span className={`text-xs font-semibold`}>{customField.name}</span>
<span dangerouslySetInnerHTML={{__html: customField.value}} className='ap-profile-content truncate'/>
</span>
))}
{!isExpanded && isOverflowing && (
<div className='absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white via-white/90 via-60% to-transparent' />
)}
{isOverflowing && <Button
className='absolute bottom-0 text-pink'
label={isExpanded ? 'Show less' : 'Show all'}
link={true}
onClick={toggleExpand}
/>}
</div>
)}
<TabView<ProfileTab>
containerClassName='mt-6'
selectedTab={selectedTab}
tabs={tabs}
onTabChange={setSelectedTab}
<div className='z-0 mx-auto mt-8 flex w-full max-w-[580px] flex-col items-center pb-16'>
<div className='mx-auto w-full'>
{account?.bannerImageUrl && (
<div className='h-[200px] w-full overflow-hidden rounded-lg bg-gradient-to-tr from-gray-200 to-gray-100'>
<img
alt={account?.name}
className='h-full w-full object-cover'
src={account?.bannerImageUrl}
/>
</div>
)}
<div className={`${account?.bannerImageUrl && '-mt-12'} px-4`}>
<div className='flex items-end justify-between'>
<div className='rounded-xl outline outline-4 outline-white'>
<APAvatar
author={account && {
icon: {
url: account?.avatarUrl
},
name: account?.name,
handle: account?.handle
}}
size='lg'
/>
</div>
</div>
<Heading className='mt-4' level={3}>{!isLoadingAccount ? account?.name : <Skeleton className='w-32' />}</Heading>
<span className='mt-1 text-[1.5rem] text-gray-800'>
<span>{!isLoadingAccount ? account?.handle : <Skeleton className='w-full max-w-56' />}</span>
</span>
{(account?.bio || customFields.length > 0 || isLoadingAccount) && (
<div ref={contentRef} className={`ap-profile-content transition-max-height relative text-[1.5rem] duration-300 ease-in-out [&>p]:mb-3 ${isExpanded ? 'max-h-none pb-7' : 'max-h-[160px] overflow-hidden'} relative`}>
<div className='ap-profile-content mt-3 text-[1.5rem] [&>p]:mb-3'>
{!isLoadingAccount ?
<div dangerouslySetInnerHTML={{__html: account?.bio ?? ''}} /> :
<>
<Skeleton />
<Skeleton className='w-full max-w-48' />
</>
}
</div>
{customFields.map(customField => (
<span key={customField.name} className='mt-3 line-clamp-1 flex flex-col text-[1.5rem]'>
<span className={`text-xs font-semibold`}>{customField.name}</span>
<span dangerouslySetInnerHTML={{__html: customField.value}} className='ap-profile-content truncate'/>
</span>
))}
{!isExpanded && isOverflowing && (
<div className='absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-white via-white/90 via-60% to-transparent' />
)}
{isOverflowing && <Button
className='absolute bottom-0 text-pink'
label={isExpanded ? 'Show less' : 'Show all'}
link={true}
onClick={toggleExpand}
/>}
</div>
)}
<TabView<ProfileTab>
containerClassName='mt-6'
selectedTab={selectedTab}
tabs={tabs}
onTabChange={setSelectedTab}
/>
</div>
</div>
)}
</div>
</>
);
};

View file

@ -1,4 +1,5 @@
import React, {useEffect, useRef, useState} from 'react';
import {Skeleton} from '@tryghost/shade';
import NiceModal from '@ebay/nice-modal-react';
import {Button, Icon, LoadingIndicator, NoValueLabel, TextField} from '@tryghost/admin-x-design-system';
@ -95,9 +96,10 @@ const SearchResults: React.FC<SearchResultsProps> = ({results, onUpdate}) => {
interface SuggestedProfileProps {
profile: Profile;
update: (id: string, updated: Partial<Profile>) => void;
isLoading: boolean;
}
const SuggestedProfile: React.FC<SuggestedProfileProps> = ({profile, update}) => {
const SuggestedProfile: React.FC<SuggestedProfileProps> = ({profile, update, isLoading}) => {
const onFollow = () => {
update(profile.actor.id, {
isFollowing: true,
@ -120,18 +122,23 @@ const SuggestedProfile: React.FC<SuggestedProfileProps> = ({profile, update}) =>
}}
>
<APAvatar author={profile.actor}/>
<div className='flex flex-col'>
<span className='font-semibold text-black'>{profile.actor.name}</span>
<span className='text-sm text-gray-700'>{profile.handle}</span>
<div className='flex grow flex-col'>
<span className='font-semibold text-black'>{!isLoading ? profile.actor.name : <Skeleton className='w-full max-w-64' />}</span>
<span className='text-sm text-gray-700'>{!isLoading ? profile.handle : <Skeleton className='w-24' />}</span>
</div>
<FollowButton
className='ml-auto'
following={profile.isFollowing}
handle={profile.handle}
type='secondary'
onFollow={onFollow}
onUnfollow={onUnfollow}
/>
{!isLoading ?
<FollowButton
className='ml-auto'
following={profile.isFollowing}
handle={profile.handle}
type='secondary'
onFollow={onFollow}
onUnfollow={onUnfollow}
/> :
<div className='inline-flex items-center'>
<Skeleton className='w-12' />
</div>
}
</ActivityItem>
);
};
@ -148,15 +155,11 @@ const SuggestedProfiles: React.FC<SuggestedProfilesProps> = ({profiles, isLoadin
<span className='mb-1 flex w-full max-w-[560px] font-semibold'>
Suggested accounts
</span>
{isLoading && (
<div className='p-4'>
<LoadingIndicator size='md'/>
</div>
)}
{profiles.map((profile, index) => {
return (
<React.Fragment key={profile.actor.id}>
<SuggestedProfile
isLoading={isLoading}
profile={profile}
update={onUpdate}
/>
@ -174,7 +177,13 @@ const Search: React.FC<SearchProps> = ({}) => {
// Initialise suggested profiles
const {suggestedProfilesQuery, updateSuggestedProfile} = useSuggestedProfilesForUser('index', 6);
const {data: suggestedProfilesData, isLoading: isLoadingSuggestedProfiles} = suggestedProfilesQuery;
const suggestedProfiles = suggestedProfilesData || [];
const suggestedProfiles = suggestedProfilesData || Array(5).fill({
actor: {},
handle: '',
followerCount: 0,
followingCount: 0,
isFollowing: false
});
// Initialise search query
const queryInputRef = useRef<HTMLInputElement>(null);

View file

@ -1,6 +1,7 @@
import React, {useEffect, useRef, useState} from 'react';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Heading, Icon, Menu, MenuItem, showToast} from '@tryghost/admin-x-design-system';
import {Skeleton} from '@tryghost/shade';
import APAvatar from '../global/APAvatar';
@ -109,12 +110,16 @@ export function renderFeedAttachment(object: ObjectProperties, layout: string) {
}
}
function renderInboxAttachment(object: ObjectProperties) {
function renderInboxAttachment(object: ObjectProperties, isLoading: boolean | undefined) {
const attachment = getAttachment(object);
const videoAttachmentStyles = 'ml-8 md:ml-9 shrink-0 rounded-md h-[91px] w-[121px] relative';
const imageAttachmentStyles = clsx('object-cover outline outline-1 -outline-offset-1 outline-black/[0.05]', videoAttachmentStyles);
if (isLoading) {
return <Skeleton className={`${imageAttachmentStyles} outline-0`} />;
}
if (!attachment) {
return null;
}
@ -170,13 +175,14 @@ interface FeedItemProps {
repostCount?: number;
showHeader?: boolean;
last?: boolean;
isLoading?: boolean;
onClick?: () => void;
onCommentClick: () => void;
}
const noop = () => {};
const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, commentCount = 0, repostCount = 0, showHeader = true, last, onClick: onClickHandler = noop, onCommentClick}) => {
const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, commentCount = 0, repostCount = 0, showHeader = true, last, isLoading, onClick: onClickHandler = noop, onCommentClick}) => {
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'});
@ -190,7 +196,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
if (element) {
setIsTruncated(element.scrollHeight > element.clientHeight);
}
}, [object.content]);
}, [object?.content]);
const onLikeClick = () => {
// Do API req or smth
@ -266,21 +272,23 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
</div>}
<div className={`border-1 flex flex-col gap-2.5`} data-test-activity>
<div className='relative z-30 flex min-w-0 items-center gap-3'>
<APAvatar author={author}/>
<APAvatar author={author} />
<div className='flex min-w-0 flex-col gap-0.5'>
<span className='min-w-0 truncate break-all font-semibold leading-[normal] hover:underline'
data-test-activity-heading
onClick={e => handleProfileClick(author, e)}
>
{author.name}
{!isLoading ? author.name : <Skeleton className='w-24' />}
</span>
<div className='flex w-full text-gray-700'>
<span className='truncate leading-tight hover:underline'
onClick={e => handleProfileClick(author, e)}
>
{getUsername(author)}
{!isLoading ? getUsername(author) : <Skeleton className='w-56' />}
</span>
<div className='ml-1 leading-tight before:mr-1 before:content-["·"]' title={`${timestamp}`}>{renderTimestamp(object)}</div>
<div className={`ml-1 leading-tight before:mr-1 ${!isLoading && 'before:content-["·"]'}`} title={`${timestamp}`}>
{!isLoading ? renderTimestamp(object) : <Skeleton className='w-4' />}
</div>
</div>
</div>
<Menu items={menuItems} open={menuIsOpen} position='end' setOpen={setMenuIsOpen} trigger={UserMenuTrigger}/>
@ -296,11 +304,15 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
</div>
</div> :
<div className='relative'>
<div
dangerouslySetInnerHTML={({__html: object.content ?? ''})}
ref={contentRef}
className='ap-note-content line-clamp-[10] text-pretty leading-[1.4285714286] tracking-[-0.006em] text-gray-900'
></div>
<div className='ap-note-content line-clamp-[10] text-pretty leading-[1.4285714286] tracking-[-0.006em] text-gray-900'>
{!isLoading ?
<div dangerouslySetInnerHTML={{
__html: object.content ?? ''
}} ref={contentRef} />
:
<Skeleton count={2} />
}
</div>
{isTruncated && (
<button className='mt-1 text-blue-600' type='button'>Show more</button>
)}
@ -309,15 +321,18 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
}
</div>
<div className='space-between relative z-[30] ml-[-7px] mt-1 flex'>
<FeedItemStats
commentCount={commentCount}
layout={layout}
likeCount={1}
object={object}
repostCount={repostCount}
onCommentClick={onCommentClick}
onLikeClick={onLikeClick}
/>
{!isLoading ?
<FeedItemStats
commentCount={commentCount}
layout={layout}
likeCount={1}
object={object}
repostCount={repostCount}
onCommentClick={onCommentClick}
onLikeClick={onLikeClick}
/> :
<Skeleton className='ml-2 w-18' />
}
</div>
</div>
</div>
@ -439,29 +454,44 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
<>
{object && (
<div className='group/article relative -mx-6 -my-px flex min-h-[112px] min-w-0 cursor-pointer items-center justify-between rounded-lg p-6 hover:bg-gray-75' data-layout='inbox' data-object-id={object.id} onClick={onClick}>
<div className='min-w-0'>
<div className='w-full min-w-0'>
<div className='z-10 mb-1.5 flex w-full min-w-0 items-center gap-1.5 text-sm group-hover/article:border-transparent'>
<APAvatar author={author} size='2xs'/>
<span className='min-w-0 truncate break-all font-semibold text-gray-900 hover:underline'
title={getUsername(author)}
data-test-activity-heading
onClick={e => handleProfileClick(author, e)}
>{author.name}
</span>
{(type === 'Announce') && <span className='z-10 flex items-center gap-1 text-gray-700'><Icon colorClass='text-gray-700 shrink-0' name='reload' size={'sm'}></Icon><span className='hover:underline' title={getUsername(actor)} onClick={e => handleProfileClick(actor, e)}>{actor.name}</span> reposted</span>}
<span className='shrink-0 whitespace-nowrap text-gray-600 before:mr-1 before:content-["·"]' title={`${timestamp}`}>{renderTimestamp(object)}</span>
{!isLoading ?
<>
<APAvatar author={author} size='2xs' />
<span className='min-w-0 truncate break-all font-semibold text-gray-900 hover:underline'
title={getUsername(author)}
data-test-activity-heading
onClick={e => handleProfileClick(author, e)}
>{author.name}
</span>
{(type === 'Announce') && <span className='z-10 flex items-center gap-1 text-gray-700'><Icon colorClass='text-gray-700 shrink-0' name='reload' size={'sm'}></Icon><span className='hover:underline' title={getUsername(actor)} onClick={e => handleProfileClick(actor, e)}>{actor.name}</span> reposted</span>}
<span className='shrink-0 whitespace-nowrap text-gray-600 before:mr-1 before:content-["·"]' title={`${timestamp}`}>{renderTimestamp(object)}</span>
</> :
<Skeleton className='w-24' />
}
</div>
<div className='flex'>
<div className='flex min-h-[73px] w-full min-w-0 flex-col items-start justify-start gap-1'>
<Heading className='w-full max-w-[600px] text-pretty text-[1.6rem] font-semibold leading-tight' level={5} data-test-activity-heading>
{object.name ? object.name : (
{isLoading ? <Skeleton className='w-full max-w-96' /> : (object.name ? object.name : (
<span dangerouslySetInnerHTML={{
__html: stripHtml(object.content || '')
}}></span>
)}
))}
</Heading>
<div dangerouslySetInnerHTML={({__html: stripHtml(object.preview?.content ?? object.content ?? '')})} className='ap-note-content line-clamp-2 w-full max-w-[600px] text-pretty text-base leading-normal text-gray-800'></div>
<span className='mt-1 shrink-0 whitespace-nowrap text-sm leading-none text-gray-600'>{object.content && `${getReadingTime(object.content)}`}</span>
<div className='ap-note-content line-clamp-2 w-full max-w-[600px] text-pretty text-base leading-normal text-gray-800'>
{!isLoading ?
<div dangerouslySetInnerHTML={{
__html: stripHtml(object.preview?.content ?? object.content ?? '')
}} />
:
<Skeleton count={2} />
}
</div>
<span className='mt-1 shrink-0 whitespace-nowrap text-sm leading-none text-gray-600'>
{!isLoading ? (object.content && `${getReadingTime(object.content)}`) : <Skeleton className='w-16' />}
</span>
</div>
<div className='invisible absolute right-4 top-1/2 z-[49] flex -translate-y-1/2 flex-col rounded-full bg-white p-1 shadow-md group-hover/article:visible'>
<FeedItemStats
@ -477,7 +507,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
</div>
</div>
</div>
{renderInboxAttachment(object)}
{renderInboxAttachment(object, isLoading)}
</div>
)}
</>

View file

@ -5,6 +5,7 @@ import clsx from 'clsx';
import getUsername from '../../utils/get-username';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Icon} from '@tryghost/admin-x-design-system';
import {Skeleton} from '@tryghost/shade';
type AvatarSize = '2xs' | 'xs' | 'sm' | 'lg' | 'notification';
@ -17,9 +18,10 @@ interface APAvatarProps {
handle?: string;
} | undefined;
size?: AvatarSize;
isLoading?: boolean;
}
const APAvatar: React.FC<APAvatarProps> = ({author, size}) => {
const APAvatar: React.FC<APAvatarProps> = ({author, size, isLoading = false}) => {
let iconSize = 18;
let containerClass = `shrink-0 items-center justify-center overflow-hidden relative z-10 flex ${size === 'lg' ? '' : 'hover:opacity-80 cursor-pointer'}`;
let imageClass = 'z-10 object-cover';
@ -29,10 +31,6 @@ const APAvatar: React.FC<APAvatarProps> = ({author, size}) => {
setIconUrl(author?.icon?.url);
}, [author?.icon?.url]);
if (!author) {
return null;
}
switch (size) {
case '2xs':
iconSize = 10;
@ -63,6 +61,10 @@ const APAvatar: React.FC<APAvatarProps> = ({author, size}) => {
break;
}
if (!author || isLoading) {
return <Skeleton className={imageClass} containerClassName={containerClass} />;
}
if (!iconUrl) {
containerClass = clsx(containerClass, 'bg-gray-100');
}

View file

@ -0,0 +1,17 @@
import type {Meta, StoryObj} from '@storybook/react';
import {Skeleton} from './skeleton';
const meta = {
title: 'Components / Skeleton',
component: Skeleton,
tags: ['autodocs']
} satisfies Meta<typeof Skeleton>;
export default meta;
type Story = StoryObj<typeof Skeleton>;
export const Default: Story = {
args: {
style: {width: 160, height: 16}
}
};

View file

@ -1,14 +1,29 @@
import React from 'react';
import {cn} from '@/lib/utils';
interface SkeletonProps extends React.HTMLAttributes<HTMLSpanElement> {
containerClassName?: string;
count?: number;
}
function Skeleton({
containerClassName,
count = 1,
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
}: SkeletonProps) {
return (
<div
className={cn('animate-pulse rounded-md bg-primary/10', className)}
{...props}
/>
<span className={containerClassName}>
{Array.from({length: count}).map(() => (
<React.Fragment key={`skeleton-${crypto.randomUUID()}`}>
<span
className={cn('inline-flex w-full leading-none animate-pulse rounded-[2px] bg-primary/10', className)}
{...props}
>&zwnj;</span>
<br />
</React.Fragment>
))}
</span>
);
}