Improved ActivityPub drawer view UI (#21521)

ref https://linear.app/ghost/issue/AP-507/inbox-view-missing-min-width-for-inbox-card, https://linear.app/ghost/issue/AP-562/remove-unused-viewfollowersmodal-and-viewfollowingmodal-files, https://linear.app/ghost/issue/AP-559/add-post-stats-and-buttons-to-articles-in-drawer-view, https://linear.app/ghost/issue/AP-468/drawer-visual-refinements, https://linear.app/ghost/issue/AP-558/add-actor-info-to-articles-in-drawer-view, https://linear.app/ghost/issue/AP-573/add-anchor-link-to-replies-in-the-drawer

- Made `Articles` in drawer view wider for better reading experience
- Added `Actor` info to `Articles` in drawer view for more context about who posted it
- Added `Like` and `Reply` buttons and counters to `Articles` in drawer view
- Clicking on a Reply notification or Reply icon in the drawer view now scrolls you directly to replies
- Removed modals we’re no longer using
- Updated `RoutingProvider` so it can work without any modals passed to
it
This commit is contained in:
Djordje Vlaisavljevic 2024-11-07 15:59:29 +00:00 committed by GitHub
parent 28062367d9
commit 3709a4811e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 305 additions and 350 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@tryghost/admin-x-activitypub",
"version": "0.3.10",
"version": "0.3.11",
"license": "MIT",
"repository": {
"type": "git",

View File

@ -8,19 +8,10 @@ interface AppProps {
designSystem: DesignSystemAppProps;
}
const modals = {
paths: {
'follow-site': 'FollowSite',
'profile/following': 'ViewFollowing',
'profile/followers': 'ViewFollowers'
},
load: async () => import('./components/modals')
};
const App: React.FC<AppProps> = ({framework, designSystem}) => {
return (
<FrameworkProvider {...framework}>
<RoutingProvider basePath='activitypub' modals={modals}>
<RoutingProvider basePath='activitypub'>
<DesignSystemApp className='admin-x-activitypub' {...designSystem}>
<MainContent />
</DesignSystemApp>

View File

@ -7,14 +7,12 @@ import {LoadingIndicator, NoValueLabel} from '@tryghost/admin-x-design-system';
import APAvatar, {AvatarBadge} from './global/APAvatar';
import ActivityItem, {type Activity} from './activities/ActivityItem';
import ArticleModal from './feed/ArticleModal';
// import FollowButton from './global/FollowButton';
import MainNavigation from './navigation/MainNavigation';
import ViewProfileModal from './global/ViewProfileModal';
import getUsername from '../utils/get-username';
import stripHtml from '../utils/strip-html';
import {useActivitiesForUser} from '../hooks/useActivityPubQueries';
// import {useFollowersForUser} from '../MainContent';
interface ActivitiesProps {}
@ -29,7 +27,7 @@ const getActivityDescription = (activity: Activity): string => {
switch (activity.type) {
case ACTVITY_TYPE.CREATE:
if (activity.object?.inReplyTo && typeof activity.object?.inReplyTo !== 'string') {
return `Commented on your article "${activity.object.inReplyTo.name}"`;
return `Replied to your article "${activity.object.inReplyTo.name}"`;
}
return '';
@ -141,7 +139,8 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
NiceModal.show(ArticleModal, {
activityId: activity.id,
object: activity.object,
actor: activity.actor
actor: activity.actor,
focusReplies: true
});
break;
case ACTVITY_TYPE.LIKE:

View File

@ -76,7 +76,7 @@ const Inbox: React.FC<InboxProps> = ({}) => {
<>
<div className={`mx-auto flex items-start ${layout === 'inbox' ? 'max-w-6xl gap-14' : 'gap-8'}`}>
<div className='flex w-full min-w-0 items-start'>
<ul className={`mx-auto flex ${layout === 'inbox' ? 'max-w-full' : 'max-w-[500px]'} flex-col`}>
<ul className={`mx-auto flex ${layout === 'inbox' ? 'w-full max-w-full' : 'max-w-[500px]'} flex-col`}>
{activities.map((activity, index) => (
<li
key={activity.id}

View File

@ -427,17 +427,13 @@ button.gh-form-input {
.gh-canvas,
.kg-width-full.kg-content-wide {
--main: min(var(--content-width, 720px), 100% - var(--container-gap) * 2);
--main: minmax(0, 1fr);
--wide: minmax(0, calc((var(--container-width, 1200px) - var(--content-width, 720px)) / 2));
--full: minmax(var(--container-gap), 1fr);
display: grid;
grid-template-columns:
[full-start] var(--full)
[wide-start] var(--wide)
[main-start] var(--main) [main-end]
var(--wide) [wide-end]
var(--full) [full-end];
}
.gh-canvas > * {
@ -2008,7 +2004,7 @@ Search LOGO Login Subscribe
}
.gh-article-header {
margin: clamp(40px, 3.64vw + 25.45px, 72px) 0 40px;
margin: 24px 0;
}
.gh-article-tag {
@ -2031,7 +2027,7 @@ Search LOGO Login Subscribe
.gh-article-excerpt {
margin-top: 16px;
font-size: 2.1rem;
line-height: 1.25;
line-height: 1.3;
letter-spacing: -0.63px;
text-wrap: pretty;
}

View File

@ -1,23 +1,29 @@
import React, {useEffect, useRef, useState} from 'react';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, LoadingIndicator, Modal} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import FeedItem from './FeedItem';
import FeedItemStats from './FeedItemStats';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import articleBodyStyles from '../articleBodyStyles';
import getUsername from '../../utils/get-username';
import {type Activity} from '../activities/ActivityItem';
import APReplyBox from '../global/APReplyBox';
import FeedItem from './FeedItem';
import articleBodyStyles from '../articleBodyStyles';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, LoadingIndicator, Modal} from '@tryghost/admin-x-design-system';
import {renderTimestamp} from '../../utils/render-timestamp';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useEffect, useRef, useState} from 'react';
import {useModal} from '@ebay/nice-modal-react';
import {useThreadForUser} from '../../hooks/useActivityPubQueries';
import APAvatar from '../global/APAvatar';
import APReplyBox from '../global/APReplyBox';
interface ArticleModalProps {
activityId: string;
object: ObjectProperties;
actor: ActorProperties;
focusReply: boolean;
focusReplies: boolean;
width?: 'narrow' | 'wide';
updateActivity: (id: string, updated: Partial<Activity>) => void;
history: {
activityId: string;
@ -130,7 +136,7 @@ const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt:
}, [htmlContent]);
return (
<div className='w-full border-b border-grey-200 pb-10'>
<div className='w-full pb-10'>
<iframe
ref={iframeRef}
id='gh-ap-article-iframe'
@ -155,18 +161,14 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
object,
actor,
focusReply,
focusReplies,
width = 'narrow',
updateActivity = () => {},
history = []
}) => {
const MODAL_SIZE_SM = 640;
const [isFocused, setFocused] = useState(focusReply ? 1 : 0);
function setReplyBoxFocused(focused: boolean) {
if (focused) {
setFocused(prev => prev + 1);
} else {
setFocused(0);
}
}
const MODAL_SIZE_LG = 840;
const [isFocused] = useState(focusReply ? 1 : 0);
const {threadQuery, addToThread} = useThreadForUser('index', activityId);
const {data: activityThread, isLoading: isLoadingThread} = threadQuery;
@ -174,7 +176,7 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
const activityThreadChildren = (activityThread?.items ?? []).slice(activtyThreadActivityIdx + 1);
const activityThreadParents = (activityThread?.items ?? []).slice(0, activtyThreadActivityIdx);
const [modalSize] = useState<number>(MODAL_SIZE_SM);
const modalSize = width === 'narrow' ? MODAL_SIZE_SM : MODAL_SIZE_LG;
const modal = useModal();
const canNavigateBack = history.length > 0;
@ -193,10 +195,11 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
object: prevProps.object,
actor: prevProps.actor,
updateActivity,
width,
history
});
};
const navigateForward = (nextActivityId: string, nextObject: ObjectProperties, nextActor: ActorProperties) => {
const navigateForward = (nextActivityId: string, nextObject: ObjectProperties, nextActor: ActorProperties, nextFocusReply: boolean) => {
// Trigger the modal to show the next activity and add the existing
// activity to the history so we can navigate back
modal.show({
@ -204,6 +207,8 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
object: nextObject,
actor: nextActor,
updateActivity,
width,
focusReply: nextFocusReply,
history: [
...history,
{
@ -215,6 +220,11 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
});
};
const onLikeClick = () => {
// Do API req or smth
// Don't need to know about setting timeouts or anything like that
};
function handleNewReply(activity: Activity) {
// Add the new reply to the thread
addToThread(activity);
@ -234,14 +244,24 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
}
const replyBoxRef = useRef<HTMLDivElement>(null);
const repliesRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (focusReply && replyBoxRef.current) {
setTimeout(() => {
replyBoxRef.current?.scrollIntoView({block: 'center'});
}, 100);
}
}, [focusReply]);
// Combine both scroll behaviors into a single effect
setTimeout(() => {
if (focusReply && replyBoxRef.current) {
replyBoxRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
} else if (focusReplies && repliesRef.current) {
repliesRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
}, 100);
}, [focusReply, focusReplies]);
return (
<Modal
@ -254,13 +274,26 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
width={modalSize}
>
<div className='flex h-full flex-col'>
<div className='sticky top-0 z-50 border-b border-grey-200 bg-white py-3'>
<div className='grid h-8 grid-cols-3'>
{canNavigateBack && (
<div className='sticky top-0 z-50 border-b border-grey-200 bg-white py-8'>
<div className={`flex h-8 ${object.type === 'Article' && 'pl-[98px]'}`}>
{(canNavigateBack || (activityThreadParents.length > 0)) ? (
<div className='col-[1/2] flex items-center justify-between px-8'>
<Button icon='chevron-left' size='sm' unstyled onClick={navigateBack}/>
</div>
)}
) : <div className='flex items-center gap-3 px-8'>
<div className='relative z-10 pt-[3px]'>
<APAvatar author={actor}/>
</div>
<div className='relative z-10 flex w-full min-w-0 flex-col overflow-visible text-[1.5rem]'>
<div className='flex w-full'>
<span className='min-w-0 truncate whitespace-nowrap font-bold after:mx-1 after:font-normal after:text-grey-700 after:content-["·"]'>{actor.name}</span>
<div>{renderTimestamp(object)}</div>
</div>
<div className='flex w-full'>
<span className='min-w-0 truncate text-grey-700'>{getUsername(actor)}</span>
</div>
</div>
</div>}
<div className='col-[2/3] flex grow items-center justify-center px-8 text-center'>
</div>
<div className='col-[3/4] flex items-center justify-end space-x-6 px-8'>
@ -268,32 +301,26 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
</div>
</div>
</div>
<div className='grow overflow-y-auto'>
<div className='mx-auto max-w-[580px] pb-10 pt-5'>
{activityThreadParents.map((item) => {
return (
<>
{item.object.type === 'Article' ? (
<ArticleBody
excerpt={item.object?.preview?.content}
heading={item.object.name}
html={item.object.content}
image={item.object?.image}
/>
) : (
<FeedItem
actor={item.actor}
commentCount={item.object.replyCount ?? 0}
last={false}
layout='reply'
object={item.object}
type='Note'
onClick={() => {
navigateForward(item.id, item.object, item.actor);
}}
onCommentClick={() => {}}
/>
)}
<FeedItem
actor={item.actor}
commentCount={item.object.replyCount ?? 0}
last={false}
layout='reply'
object={item.object}
type='Note'
onClick={() => {
navigateForward(item.id, item.object, item.actor, false);
}}
onCommentClick={() => {
navigateForward(item.id, item.object, item.actor, true);
}}
/>
</>
);
})}
@ -303,21 +330,40 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
actor={actor}
commentCount={object.replyCount ?? 0}
last={true}
layout={activityThreadParents.length > 0 ? 'modal' : 'modal'}
layout={'modal'}
object={object}
showHeader={(canNavigateBack || (activityThreadParents.length > 0)) ? true : false}
type='Note'
onCommentClick={() => {
setReplyBoxFocused(true);
repliesRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}}
/>
)}
{object.type === 'Article' && (
<ArticleBody
excerpt={object?.preview?.content}
heading={object.name}
html={object.content}
image={object?.image}
/>
<div className='border-b border-grey-200 pb-8'>
<ArticleBody
excerpt={object?.preview?.content}
heading={object.name}
html={object.content}
image={object?.image}
/>
<FeedItemStats
commentCount={object.replyCount ?? 0}
layout={'modal'}
likeCount={1}
object={object}
onCommentClick={() => {
repliesRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}}
onLikeClick={onLikeClick}
/>
</div>
)}
<div ref={replyBoxRef}>
@ -331,27 +377,31 @@ const ArticleModal: React.FC<ArticleModalProps> = ({
{isLoadingThread && <LoadingIndicator size='lg' />}
{activityThreadChildren.map((item, index) => {
const showDivider = index !== activityThreadChildren.length - 1;
<div ref={repliesRef}>
{activityThreadChildren.map((item, index) => {
const showDivider = index !== activityThreadChildren.length - 1;
return (
<>
<FeedItem
actor={item.actor}
commentCount={item.object.replyCount ?? 0}
last={true}
layout='reply'
object={item.object}
type='Note'
onClick={() => {
navigateForward(item.id, item.object, item.actor);
}}
onCommentClick={() => {}}
/>
{showDivider && <FeedItemDivider />}
</>
);
})}
return (
<>
<FeedItem
actor={item.actor}
commentCount={item.object.replyCount ?? 0}
last={true}
layout='reply'
object={item.object}
type='Note'
onClick={() => {
navigateForward(item.id, item.object, item.actor, false);
}}
onCommentClick={() => {
navigateForward(item.id, item.object, item.actor, true);
}}
/>
{showDivider && <FeedItemDivider />}
</>
);
})}
</div>
</div>
</div>
</div>

View File

@ -4,10 +4,11 @@ import {Button, Heading, Icon, Menu, MenuItem, showToast} from '@tryghost/admin-
import APAvatar from '../global/APAvatar';
import FeedItemStats from './FeedItemStats';
import getRelativeTimestamp from '../../utils/get-relative-timestamp';
import getUsername from '../../utils/get-username';
import stripHtml from '../../utils/strip-html';
import {useLikeMutationForUser, useUnlikeMutationForUser} from '../../hooks/useActivityPubQueries';
import {renderTimestamp} from '../../utils/render-timestamp';
function getAttachment(object: ObjectProperties) {
let attachment;
@ -148,86 +149,13 @@ function renderInboxAttachment(object: ObjectProperties) {
}
}
function renderTimestamp(object: ObjectProperties) {
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'});
const date = new Date(object?.published ?? new Date());
return (<a className='whitespace-nowrap text-grey-700 hover:underline' href={object.url} title={`${timestamp}`}>{getRelativeTimestamp(date)}</a>);
}
const FeedItemStats: React.FC<{
object: ObjectProperties;
likeCount: number;
commentCount: number;
layout: string;
onLikeClick: () => void;
onCommentClick: () => void;
}> = ({object, likeCount, commentCount, layout, onLikeClick, onCommentClick}) => {
const [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(object.liked);
const likeMutation = useLikeMutationForUser('index');
const unlikeMutation = useUnlikeMutationForUser('index');
const handleLikeClick = async (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
setIsClicked(true);
if (!isLiked) {
likeMutation.mutate(object.id);
} else {
unlikeMutation.mutate(object.id);
}
setIsLiked(!isLiked);
onLikeClick();
setTimeout(() => setIsClicked(false), 300);
};
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-opacity hover:opacity-60 ${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();
if (e) {
handleLikeClick(e);
}
}}
/>
{isLiked && (layout !== 'inbox') && <span className={`text-grey-900`}>{new Intl.NumberFormat().format(likeCount)}</span>}
</div>
<div className='flex gap-1'>
<Button
className={`self-start text-grey-900 hover:opacity-60 ${isClicked ? 'bump' : ''}`}
hideLabel={true}
icon='comment'
id='comment'
size='md'
unstyled={true}
onClick={(e?: React.MouseEvent<HTMLElement>) => {
e?.stopPropagation();
onCommentClick();
}}
/>
{commentCount > 0 && (layout !== 'inbox') && (
<span className={`text-grey-900`}>{new Intl.NumberFormat().format(commentCount)}</span>
)}
</div>
</div>);
};
interface FeedItemProps {
actor: ActorProperties;
object: ObjectProperties;
layout: string;
type: string;
commentCount?: number;
showHeader?: boolean;
last?: boolean;
onClick?: () => void;
onCommentClick: () => void;
@ -235,7 +163,7 @@ interface FeedItemProps {
const noop = () => {};
const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, commentCount = 0, last, onClick = noop, onCommentClick}) => {
const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, commentCount = 0, showHeader = true, last, onClick = 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'});
@ -323,8 +251,8 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
<div className='flex flex-col'>
<div className='mt-[-24px]'>
{(object.type === 'Article') && renderFeedAttachment(object, layout)}
{object.name && <Heading className='my-1 leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>}
{(object.preview && object.type === 'Article') ? object.preview.content : <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>}
{object.name && <Heading className='my-1 text-pretty leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>}
{(object.preview && object.type === 'Article') ? <div className='line-clamp-3 leading-tight'>{object.preview.content}</div> : <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>}
{(object.type === 'Note') && renderFeedAttachment(object, layout)}
{(object.type === 'Article') && <Button
className={`mt-3 self-start text-grey-900 transition-all hover:opacity-60`}
@ -364,7 +292,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
<span className='z-10'>{actor.name} reposted</span>
</div>}
<div className={`z-10 -my-1 grid grid-cols-[auto_1fr] grid-rows-[auto_1fr] gap-3 pb-4 pt-5`} data-test-activity>
<div className='relative z-10 pt-[3px]'>
{(showHeader) && <><div className='relative z-10 pt-[3px]'>
<APAvatar author={author}/>
</div>
<div className='relative z-10 flex w-full min-w-0 flex-col overflow-visible text-[1.5rem]'>
@ -375,11 +303,11 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
<div className='flex w-full'>
<span className='min-w-0 truncate text-grey-700'>{getUsername(author)}</span>
</div>
</div>
</div></>}
<div className={`relative z-10 col-start-1 col-end-3 w-full gap-4`}>
<div className='flex flex-col'>
{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.7rem] text-grey-900'></div>
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.8rem] text-grey-900'></div>
{renderFeedAttachment(object, layout)}
<div className='space-between mt-5 flex'>
<FeedItemStats
@ -427,9 +355,18 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
</div>
<div className={`relative z-10 col-start-2 col-end-3 w-full gap-4`}>
<div className='flex flex-col'>
{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)}
{(object.type === 'Article') && renderFeedAttachment(object, layout)}
{object.name && <Heading className='my-1 text-pretty leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>}
{(object.preview && object.type === 'Article') ? <div className='line-clamp-3 leading-tight'>{object.preview.content}</div> : <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>}
{(object.type === 'Note') && renderFeedAttachment(object, layout)}
{(object.type === 'Article') && <Button
className={`mt-3 self-start text-grey-900 transition-all hover:opacity-60`}
color='grey'
fullWidth={true}
id='read-more'
label='Read more'
size='md'
/>}
<div className='space-between mt-5 flex'>
<FeedItemStats
commentCount={commentCount}

View File

@ -0,0 +1,80 @@
import React, {useState} from 'react';
import {Button} from '@tryghost/admin-x-design-system';
import {ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {useLikeMutationForUser, useUnlikeMutationForUser} from '../../hooks/useActivityPubQueries';
interface FeedItemStatsProps {
object: ObjectProperties;
likeCount: number;
commentCount: number;
layout: string;
onLikeClick: () => void;
onCommentClick: () => void;
}
const FeedItemStats: React.FC<FeedItemStatsProps> = ({
object,
likeCount,
commentCount,
layout,
onLikeClick,
onCommentClick
}) => {
const [isClicked, setIsClicked] = useState(false);
const [isLiked, setIsLiked] = useState(object.liked);
const likeMutation = useLikeMutationForUser('index');
const unlikeMutation = useUnlikeMutationForUser('index');
const handleLikeClick = async (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
setIsClicked(true);
if (!isLiked) {
likeMutation.mutate(object.id);
} else {
unlikeMutation.mutate(object.id);
}
setIsLiked(!isLiked);
onLikeClick();
setTimeout(() => setIsClicked(false), 300);
};
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-opacity hover:opacity-60 ${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();
if (e) {
handleLikeClick(e);
}
}}
/>
{isLiked && (layout !== 'inbox') && <span className={`text-grey-900`}>{new Intl.NumberFormat().format(likeCount)}</span>}
</div>
<div className='flex gap-1'>
<Button
className={`self-start text-grey-900 hover:opacity-60 ${isClicked ? 'bump' : ''}`}
hideLabel={true}
icon='comment'
id='comment'
size='md'
unstyled={true}
onClick={(e?: React.MouseEvent<HTMLElement>) => {
e?.stopPropagation();
onCommentClick();
}}
/>
{commentCount > 0 && (layout !== 'inbox') && (
<span className={`text-grey-900`}>{new Intl.NumberFormat().format(commentCount)}</span>
)}
</div>
</div>);
};
export default FeedItemStats;

View File

@ -93,7 +93,7 @@ const APReplyBox: React.FC<APTextAreaProps> = ({
}
const styles = clsx(
`ap-textarea order-2 w-full resize-none rounded-lg border py-2 pr-3 text-[1.5rem] transition-all dark:text-white ${isFocused && 'pb-12'}`,
`ap-textarea order-2 w-full resize-none rounded-lg border bg-transparent py-2 pr-3 text-[1.5rem] transition-all dark:text-white ${isFocused && 'pb-12'}`,
error ? 'border-red' : 'border-transparent placeholder:text-grey-500 dark:placeholder:text-grey-800',
title && 'mt-1.5',
className

View File

@ -1,10 +0,0 @@
import ViewFollowers from './profile/ViewFollowersModal';
import ViewFollowing from './profile/ViewFollowingModal';
import {ModalComponent} from '@tryghost/admin-x-framework/routing';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const modals = {ViewFollowing, ViewFollowers} satisfies {[key: string]: ModalComponent<any>};
export default modals;
export type ModalName = keyof typeof modals;

View File

@ -1,68 +0,0 @@
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 {useQuery} from '@tanstack/react-query';
function useFollowersForUser(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: [`followers:${handle}`],
async queryFn() {
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 {data: followers = [], isLoading} = useFollowersForUser('index');
return (
<Modal
afterClose={() => {
updateRoute('profile');
}}
cancelLabel=''
footer={false}
okLabel=''
size='md'
title='Followers'
topRightContent='close'
>
<div className='mt-3 flex flex-col gap-4 pb-12'>
{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>
);
};
export default NiceModal.create(ViewFollowersModal);

View File

@ -1,61 +0,0 @@
import NiceModal from '@ebay/nice-modal-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 {useQuery} from '@tanstack/react-query';
function useFollowingForUser(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: [`following:${handle}`],
async queryFn() {
return api.getFollowing();
}
});
}
const ViewFollowingModal: React.FC<RoutingModalProps> = ({}) => {
const {updateRoute} = useRouting();
const {data: following = []} = useFollowingForUser('index');
return (
<Modal
afterClose={() => {
updateRoute('profile');
}}
cancelLabel=''
footer={false}
okLabel=''
size='md'
title='Following'
topRightContent='close'
>
<div className='mt-3 flex flex-col gap-4 pb-12'>
<List>
{following.map(item => (
<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>
</div>
</Modal>
);
};
export default NiceModal.create(ViewFollowingModal);

View File

@ -1,48 +1,55 @@
@import '@tryghost/admin-x-design-system/styles.css';
.admin-x-base.admin-x-activitypub {
animation-name: none;
animation-name: none;
}
@keyframes bump {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.bump {
animation: bump 0.3s ease-in-out;
animation: bump 0.3s ease-in-out;
}
.ap-red-heart path {
fill: #F50B23;
fill: #F50B23;
}
.ap-note-content a, .ap-profile-content a {
color: rgb(37 99 235) !important;
.ap-note-content a,
.ap-profile-content a {
color: #2563eb !important;
word-break: break-all;
}
.ap-note-content a:hover, .ap-profile-content a:hover {
color: rgb(29 78 216) !important;
.ap-note-content a:hover,
.ap-profile-content a:hover {
color: #1d4ed8 !important;
text-decoration: underline !important;
}
.ap-note-content span.invisible, .ap-profile-content span.invisible {
.ap-note-content span.invisible,
.ap-profile-content span.invisible {
display: none;
}
.ap-note-content a:not(.hashtag) span.ellipsis:after, .ap-profile-content a:not(.hashtag) span.ellipsis:after, .ap-likes .ellipsis::after {
.ap-note-content a:not(.hashtag) span.ellipsis:after,
.ap-profile-content a:not(.hashtag) span.ellipsis:after,
.ap-likes .ellipsis::after {
content: "…";
}
.ap-note-content p + p {
.ap-note-content p+p {
margin-top: 1.5rem !important;
}
@ -57,4 +64,4 @@ animation: bump 0.3s ease-in-out;
.ap-textarea {
field-sizing: content;
}
}

View File

@ -12,8 +12,8 @@ export const handleViewContent = (
activityId: activity.id,
object: activity.object,
actor: authorActor,
comments: Array.isArray(activity.object.replies) ? activity.object.replies : [],
focusReply,
width: activity.object.type === 'Article' ? 'wide' : 'narrow',
updateActivity
});
};

View File

@ -0,0 +1,28 @@
import getRelativeTimestamp from './get-relative-timestamp';
import {ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
export function formatTimestamp(date: Date): string {
return new Date(date).toLocaleDateString('default', {
year: 'numeric',
month: 'short',
day: '2-digit'
}) + ', ' + new Date(date).toLocaleTimeString('default', {
hour: '2-digit',
minute: '2-digit'
});
}
export function renderTimestamp(object: ObjectProperties) {
const date = new Date(object?.published ?? new Date());
const timestamp = formatTimestamp(date);
return (
<a
className='whitespace-nowrap text-grey-700 hover:underline'
href={object.url}
title={timestamp}
>
{getRelativeTimestamp(date)}
</a>
);
}

View File

@ -63,6 +63,12 @@ const handleNavigation = (basePath: string, currentRoute: string | undefined, lo
const url = new URL(hash, domain);
const pathName = getHashPath(basePath, url.pathname);
// Return early if we don't have modal configuration
if (!modalPaths || !loadModals) {
return {pathName: pathName || ''};
}
const searchParams = url.searchParams;
if (pathName && modalPaths && loadModals) {