0
Fork 0
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:
Djordje Vlaisavljevic 2024-10-03 15:01:20 +01:00 committed by GitHub
parent 0d8ea553bd
commit 1bb09a9b7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 119 additions and 54 deletions

View file

@ -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}&hellip;”`;
};
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'>&nbsp;{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'>&nbsp;{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;

View file

@ -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';

View file

@ -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}

View file

@ -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