mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-08 02:52:39 -05:00
Updated ActivityPub inbox layout and profiles (#21200)
- Improved inbox layout for the home screen, optimized for long-form content - Added expand/collapse when profile details are too long - Added link to profile on the original server to profiles ref https://linear.app/tryghost/issue/AP-463/add-show-all-to-profiles-with-long-summaries-and-attachment-lists, https://linear.app/tryghost/issue/AP-424/inbox-and-feed-view-need-to-be-differentiated
This commit is contained in:
parent
0d8ea553bd
commit
1bb09a9b7e
4 changed files with 119 additions and 54 deletions
|
@ -99,9 +99,9 @@ function renderInboxAttachment(object: ObjectProperties) {
|
|||
const attachmentCount = attachment.length;
|
||||
|
||||
return (
|
||||
<div className='min-w-[160px]'>
|
||||
<div className='min-w-[120px]'>
|
||||
<div className='relative'>
|
||||
<img className={`h-[100px] w-[160px] rounded-md object-cover`} src={attachment[0].url} />
|
||||
<img className={`h-[80px] w-[120px] rounded-md object-cover outline outline-1 -outline-offset-1 outline-black/10`} src={attachment[0].url} />
|
||||
<div className='absolute bottom-1 right-1 z-10 rounded-full border border-[rgba(255,255,255,0.25)] bg-black px-2 py-0.5 font-semibold text-white'>+ {attachmentCount - 1}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -113,16 +113,18 @@ function renderInboxAttachment(object: ObjectProperties) {
|
|||
case 'image/png':
|
||||
case 'image/gif':
|
||||
return (
|
||||
<div className='min-w-[160px]'>
|
||||
<img className={`h-[100px] w-[160px] rounded-md object-cover outline outline-1 -outline-offset-1 outline-black/10`} src={attachment.url} />
|
||||
<div className='min-w-[120px]'>
|
||||
<img className={`h-[80px] w-[120px] rounded-md object-cover outline outline-1 -outline-offset-1 outline-black/10`} src={attachment.url} />
|
||||
</div>
|
||||
);
|
||||
case 'video/mp4':
|
||||
case 'video/webm':
|
||||
return (
|
||||
<div className='min-w-[160px]'>
|
||||
<div className='relative mb-4 mt-2'>
|
||||
<video className='h-[300px] w-full rounded object-cover' src={attachment.url} controls/>
|
||||
<div className='relative h-[80px]'>
|
||||
<video className='h-[80px] w-full rounded object-cover' src={attachment.url} />
|
||||
<div className='absolute inset-0 rounded bg-grey-900 opacity-50'></div>
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<Icon className='text-white' name='play-fill' size='lg' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -154,13 +156,25 @@ function renderTimestamp(object: ObjectProperties) {
|
|||
return (<a className='whitespace-nowrap text-grey-700 hover:underline' href={object.url} title={`${timestamp}`}>{getRelativeTimestamp(date)}</a>);
|
||||
}
|
||||
|
||||
const truncateHTML = (html: string, maxLength: number) => {
|
||||
const tempElement = document.createElement('div');
|
||||
tempElement.innerHTML = html;
|
||||
|
||||
const textContent = tempElement.textContent || tempElement.innerText || '';
|
||||
|
||||
const truncatedText = textContent.substring(0, maxLength);
|
||||
|
||||
return `“${truncatedText}…”`;
|
||||
};
|
||||
|
||||
const FeedItemStats: React.FC<{
|
||||
object: ObjectProperties;
|
||||
likeCount: number;
|
||||
commentCount: number;
|
||||
layout: string;
|
||||
onLikeClick: () => void;
|
||||
onCommentClick: () => void;
|
||||
}> = ({object, likeCount, commentCount, onLikeClick, onCommentClick}) => {
|
||||
}> = ({object, likeCount, commentCount, layout, onLikeClick, onCommentClick}) => {
|
||||
const [isClicked, setIsClicked] = useState(false);
|
||||
const [isLiked, setIsLiked] = useState(object.liked);
|
||||
const likeMutation = useLikeMutationForUser('index');
|
||||
|
@ -180,10 +194,10 @@ const FeedItemStats: React.FC<{
|
|||
setTimeout(() => setIsClicked(false), 300);
|
||||
};
|
||||
|
||||
return (<div className='flex gap-5'>
|
||||
return (<div className={`flex ${(layout === 'inbox') ? 'flex-col gap-2' : 'gap-5'}`}>
|
||||
<div className='flex gap-1'>
|
||||
<Button
|
||||
className={`self-start text-grey-900 transition-all hover:opacity-60 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`}
|
||||
className={`self-start text-grey-900 transition-opacity hover:opacity-60 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`}
|
||||
hideLabel={true}
|
||||
icon='heart'
|
||||
id='like'
|
||||
|
@ -194,7 +208,7 @@ const FeedItemStats: React.FC<{
|
|||
handleLikeClick();
|
||||
}}
|
||||
/>
|
||||
{isLiked && <span className={`text-grey-900`}>{new Intl.NumberFormat().format(likeCount)}</span>}
|
||||
{isLiked && (layout !== 'inbox') && <span className={`text-grey-900`}>{new Intl.NumberFormat().format(likeCount)}</span>}
|
||||
</div>
|
||||
<div className='flex gap-1'>
|
||||
<Button
|
||||
|
@ -209,7 +223,7 @@ const FeedItemStats: React.FC<{
|
|||
onCommentClick();
|
||||
}}
|
||||
/>
|
||||
{commentCount > 0 && (
|
||||
{commentCount > 0 && (layout !== 'inbox') && (
|
||||
<span className={`text-grey-900`}>{new Intl.NumberFormat().format(commentCount)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
@ -281,10 +295,10 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
|
|||
|
||||
const UserMenuTrigger = (
|
||||
<Button
|
||||
className={`relative z-10 ml-auto h-5 w-5 self-start ${isCopied ? 'bump' : ''}`}
|
||||
className={`relative z-10 ml-auto flex h-5 w-5 items-center justify-center self-start hover:opacity-60 ${isCopied ? 'bump' : ''}`}
|
||||
hideLabel={true}
|
||||
icon='dotdotdot'
|
||||
iconColorClass='text-grey-600'
|
||||
iconColorClass={`(${layout === 'inbox' ? 'text-grey-900' : 'text-grey-600'}`}
|
||||
id='more'
|
||||
size='sm'
|
||||
unstyled={true}
|
||||
|
@ -300,7 +314,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
|
|||
<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-6`} data-test-activity>
|
||||
<div className={`border-1 z-10 -my-1 grid grid-cols-[auto_1fr] grid-rows-[auto_1fr] gap-x-3 gap-y-2 pb-6`} data-test-activity>
|
||||
<APAvatar author={author}/>
|
||||
<div className='flex justify-between'>
|
||||
<div className='relative z-10 flex w-full flex-col overflow-visible text-[1.5rem]'>
|
||||
|
@ -332,6 +346,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
|
|||
<div className='space-between mt-5 flex'>
|
||||
<FeedItemStats
|
||||
commentCount={comments.length}
|
||||
layout={layout}
|
||||
likeCount={1}
|
||||
object={object}
|
||||
onCommentClick={onCommentClick}
|
||||
|
@ -380,6 +395,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
|
|||
<div className='space-between mt-5 flex'>
|
||||
<FeedItemStats
|
||||
commentCount={comments.length}
|
||||
layout={layout}
|
||||
likeCount={1}
|
||||
object={object}
|
||||
onCommentClick={onCommentClick}
|
||||
|
@ -429,6 +445,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
|
|||
<div className='space-between mt-5 flex'>
|
||||
<FeedItemStats
|
||||
commentCount={comments.length}
|
||||
layout={layout}
|
||||
likeCount={1}
|
||||
object={object}
|
||||
onCommentClick={onCommentClick}
|
||||
|
@ -449,33 +466,32 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
|
|||
return (
|
||||
<>
|
||||
{object && (
|
||||
<div className='group/article relative -mx-4 -mt-px cursor-pointer rounded-md px-4 hover:bg-grey-75' onClick={onClick}>
|
||||
<div className='z-10 flex items-start gap-3 py-4 group-hover/article:border-transparent'>
|
||||
<APAvatar author={author} size='xs'/>
|
||||
<div className='z-10 w-full'>
|
||||
<div className='mb-1'>
|
||||
<span className='truncate whitespace-nowrap font-semibold' data-test-activity-heading>{author.name}</span>
|
||||
<span className='truncate text-grey-700'> {getUsername(author)}</span>
|
||||
<span className='whitespace-nowrap text-grey-700 before:mx-1 before:content-["·"]' title={`${timestamp}`}>{getRelativeTimestamp(date)}</span>
|
||||
</div>
|
||||
<div className='flex w-full items-start justify-between gap-5'>
|
||||
<div className='grow'>
|
||||
{object.name && <Heading className='leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>}
|
||||
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content mt-1 line-clamp-3 text-pretty text-[1.5rem] text-grey-900'></div>
|
||||
<div className='group/article relative -mx-4 -mt-px flex cursor-pointer justify-between rounded-md p-4 hover:bg-grey-75' onClick={onClick}>
|
||||
<div className='flex w-full flex-col items-start justify-between gap-1 pr-4'>
|
||||
<div className='z-10 flex items-start justify-between gap-2 group-hover/article:border-transparent'>
|
||||
<APAvatar author={author} size='xs'/>
|
||||
<div className='z-10 w-full text-sm'>
|
||||
<div>
|
||||
<span className='truncate whitespace-nowrap font-semibold' data-test-activity-heading>{author.name}</span>
|
||||
<span className='truncate text-grey-700'> {getUsername(author)}</span>
|
||||
<span className='whitespace-nowrap text-grey-700 before:mx-1 before:content-["·"]' title={`${timestamp}`}>{getRelativeTimestamp(date)}</span>
|
||||
</div>
|
||||
{renderInboxAttachment(object)}
|
||||
</div>
|
||||
<div className='space-between mt-5 flex'>
|
||||
<FeedItemStats
|
||||
commentCount={comments.length}
|
||||
likeCount={1}
|
||||
object={object}
|
||||
onCommentClick={onCommentClick}
|
||||
onLikeClick={onLikeClick}
|
||||
/>
|
||||
<Menu items={menuItems} position='end' trigger={UserMenuTrigger}/>
|
||||
</div>
|
||||
</div>
|
||||
<Heading className='line-clamp-1 font-semibold leading-normal' level={5} data-test-activity-heading>{object.name ? object.name : <span>{author.name}: <span dangerouslySetInnerHTML={({__html: truncateHTML(object.content, 30)})}></span></span>}</Heading>
|
||||
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content line-clamp-1 text-pretty text-[1.5rem] text-grey-700'></div>
|
||||
</div>
|
||||
{renderInboxAttachment(object)}
|
||||
<div className='invisible absolute right-2 top-[9px] flex flex-col gap-2 rounded-lg bg-white p-2 shadow-md-heavy group-hover/article:visible'>
|
||||
<FeedItemStats
|
||||
commentCount={comments.length}
|
||||
layout={layout}
|
||||
likeCount={1}
|
||||
object={object}
|
||||
onCommentClick={onCommentClick}
|
||||
onLikeClick={onLikeClick}
|
||||
/>
|
||||
<Menu items={menuItems} position='end' trigger={UserMenuTrigger}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -486,4 +502,4 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
|
|||
return (<></>);
|
||||
};
|
||||
|
||||
export default FeedItem;
|
||||
export default FeedItem;
|
|
@ -34,8 +34,8 @@ const APAvatar: React.FC<APAvatarProps> = ({author, size, badge}) => {
|
|||
switch (size) {
|
||||
case 'xs':
|
||||
iconSize = 12;
|
||||
containerClass = 'z-10 relative rounded bg-grey-100 shrink-0 flex items-center justify-center w-6 h-6';
|
||||
imageClass = 'z-10 rounded w-6 h-6 object-cover';
|
||||
containerClass = 'z-10 relative rounded bg-grey-100 shrink-0 flex items-center justify-center w-5 h-5';
|
||||
imageClass = 'z-10 rounded w-5 h-5 object-cover';
|
||||
break;
|
||||
case 'sm':
|
||||
containerClass = 'z-10 relative rounded bg-grey-100 shrink-0 flex items-center justify-center w-10 h-10';
|
||||
|
|
|
@ -2,7 +2,8 @@ import React, {useEffect, useRef, useState} from 'react';
|
|||
|
||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
import {Activity, ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {Button, Heading, List, LoadingIndicator, Modal, NoValueLabel, Tab,TabView} from '@tryghost/admin-x-design-system';
|
||||
|
||||
import {Button, Heading, Icon, List, LoadingIndicator, Modal, NoValueLabel, Tab,TabView} from '@tryghost/admin-x-design-system';
|
||||
import {UseInfiniteQueryResult} from '@tanstack/react-query';
|
||||
|
||||
import {type GetFollowersForProfileResponse, type GetFollowingForProfileResponse} from '../../api/activitypub';
|
||||
|
@ -198,6 +199,21 @@ const ProfileSearchResultModal: React.FC<ProfileSearchResultModalProps> = ({
|
|||
}
|
||||
].filter(Boolean) as Tab<ProfileTab>[];
|
||||
|
||||
const [isExpanded, setisExpanded] = useState(false);
|
||||
|
||||
const toggleExpand = () => {
|
||||
setisExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
setIsOverflowing(contentRef.current.scrollHeight > 160); // Compare content height to max height
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
align='right'
|
||||
|
@ -240,17 +256,46 @@ const ProfileSearchResultModal: React.FC<ProfileSearchResultModalProps> = ({
|
|||
/>
|
||||
</div>
|
||||
<Heading className='mt-4' level={3}>{profile.actor.name}</Heading>
|
||||
<span className='mt-1 inline-block text-[1.5rem] text-grey-800'>{profile.handle}</span>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{__html: profile.actor.summary}}
|
||||
className='ap-profile-content mt-3 text-[1.5rem] [&>p]:mb-3'
|
||||
/>
|
||||
{attachments.map(attachment => (
|
||||
<span className='mt-3 line-clamp-1 flex flex-col text-[1.5rem]'>
|
||||
<span className={`text-xs font-semibold`}>{attachment.name}</span>
|
||||
<span dangerouslySetInnerHTML={{__html: attachment.value}} className='ap-profile-content truncate'/>
|
||||
</span>
|
||||
))}
|
||||
<a className='group/handle mt-1 flex items-center gap-1 text-[1.5rem] text-grey-800 hover:text-grey-900' href={profile?.actor.url} rel='noopener noreferrer' target='_blank'><span>{profile.handle}</span><Icon className='opacity-0 transition-opacity group-hover/handle:opacity-100' name='arrow-top-right' size='xs'/></a>
|
||||
{(profile.actor.summary || attachments.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: profile.actor.summary}}
|
||||
className='ap-profile-content mt-3 text-[1.5rem] [&>p]:mb-3'
|
||||
/>
|
||||
{attachments.map(attachment => (
|
||||
<span className='mt-3 line-clamp-1 flex flex-col text-[1.5rem]'>
|
||||
<span className={`text-xs font-semibold`}>{attachment.name}</span>
|
||||
<span dangerouslySetInnerHTML={{__html: attachment.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>)}
|
||||
<Heading className='mt-8' level={5}>Posts</Heading>
|
||||
|
||||
{posts.map((post) => {
|
||||
if (post.type === 'Announce') {
|
||||
return null;
|
||||
} else {
|
||||
return (
|
||||
<FeedItem
|
||||
actor={profile.actor}
|
||||
comments={post.object.replies}
|
||||
layout='feed'
|
||||
object={post.object}
|
||||
type={post.type}
|
||||
onCommentClick={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
<TabView<ProfileTab>
|
||||
containerClassName='mt-6'
|
||||
selectedTab={selectedTab}
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M23.14 10.61 2.25 0.16A1.56 1.56 0 0 0 0 1.56v20.88a1.56 1.56 0 0 0 2.25 1.4l20.89 -10.45a1.55 1.55 0 0 0 0 -2.78Z" fill="currentColor" stroke-width="1.5">
|
||||
</path>
|
||||
</svg>
|
After Width: | Height: | Size: 244 B |
Loading…
Add table
Reference in a new issue