mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-22 02:45:44 +03:00
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:
parent
28062367d9
commit
3709a4811e
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@tryghost/admin-x-activitypub",
|
||||
"version": "0.3.10",
|
||||
"version": "0.3.11",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -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>
|
||||
|
@ -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:
|
||||
|
@ -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}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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;
|
@ -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
|
||||
|
@ -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;
|
@ -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);
|
@ -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);
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
||||
});
|
||||
};
|
||||
|
28
apps/admin-x-activitypub/src/utils/render-timestamp.tsx
Normal file
28
apps/admin-x-activitypub/src/utils/render-timestamp.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user