Added comments to activities in the admin-x-activitypub app (#20958)

refs
[AP-279](https://linear.app/tryghost/issue/AP-279/handle-incoming-replies)

Wired up the comments to the activities in the admin-x-activitypub app
so that comments (replies) can be viewed by the user
This commit is contained in:
Michael Barrett 2024-09-11 17:44:12 +01:00 committed by GitHub
parent cbacea418f
commit 2cb9cf8b8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 102 additions and 48 deletions

View File

@ -4,7 +4,7 @@ import FeedItem from './feed/FeedItem';
import MainNavigation from './navigation/MainNavigation'; import MainNavigation from './navigation/MainNavigation';
import NiceModal from '@ebay/nice-modal-react'; import NiceModal from '@ebay/nice-modal-react';
import React, {useState} from 'react'; import React, {useState} from 'react';
import {Activity} from './activities/ActivityItem'; import {type Activity} from './activities/ActivityItem';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Heading} from '@tryghost/admin-x-design-system'; import {Button, Heading} from '@tryghost/admin-x-design-system';
import {useBrowseInboxForUser} from '../MainContent'; import {useBrowseInboxForUser} from '../MainContent';
@ -12,25 +12,45 @@ import {useBrowseInboxForUser} from '../MainContent';
interface InboxProps {} interface InboxProps {}
const Inbox: React.FC<InboxProps> = ({}) => { const Inbox: React.FC<InboxProps> = ({}) => {
const {data: activities = []} = useBrowseInboxForUser('index');
const [, setArticleContent] = useState<ObjectProperties | null>(null); const [, setArticleContent] = useState<ObjectProperties | null>(null);
const [, setArticleActor] = useState<ActorProperties | null>(null); const [, setArticleActor] = useState<ActorProperties | null>(null);
const [layout, setLayout] = useState('inbox'); const [layout, setLayout] = useState('inbox');
const inboxTabActivities = activities.filter((activity: Activity) => { // Retrieve activities from the inbox
const {data: inboxActivities = []} = useBrowseInboxForUser('index');
const activities = inboxActivities.filter((activity: Activity) => {
const isCreate = activity.type === 'Create' && ['Article', 'Note'].includes(activity.object.type); const isCreate = activity.type === 'Create' && ['Article', 'Note'].includes(activity.object.type);
const isAnnounce = activity.type === 'Announce' && activity.object.type === 'Note'; const isAnnounce = activity.type === 'Announce' && activity.object.type === 'Note';
return isCreate || isAnnounce; return isCreate || isAnnounce;
}); })
// API endpoint currently returns items oldest-newest, so reverse them
// to show the most recent activities first
.reverse();
const handleViewContent = (object: ObjectProperties, actor: ActorProperties) => { // Create a map of activity comments, grouping them by the parent activity
// This allows us to quickly look up all comments for a given activity
const commentsMap = new Map<string, Activity[]>();
for (const activity of activities) {
if (activity.type === 'Create' && activity.object.inReplyTo) {
const comments = commentsMap.get(activity.object.inReplyTo) ?? [];
comments.push(activity);
commentsMap.set(activity.object.inReplyTo, comments.reverse());
}
}
const getCommentsForObject = (id: string) => {
return commentsMap.get(id) ?? [];
};
const handleViewContent = (object: ObjectProperties, actor: ActorProperties, comments: Activity[]) => {
setArticleContent(object); setArticleContent(object);
setArticleActor(actor); setArticleActor(actor);
NiceModal.show(ArticleModal, { NiceModal.show(ArticleModal, {object, actor, comments});
object: object,
actor: actor
});
}; };
const handleLayoutChange = (newLayout: string) => { const handleLayoutChange = (newLayout: string) => {
@ -42,21 +62,26 @@ const Inbox: React.FC<InboxProps> = ({}) => {
<MainNavigation page='home' title="Home" onLayoutChange={handleLayoutChange} /> <MainNavigation page='home' title="Home" onLayoutChange={handleLayoutChange} />
<div className='z-0 my-5 flex w-full flex-col'> <div className='z-0 my-5 flex w-full flex-col'>
<div className='w-full'> <div className='w-full'>
{inboxTabActivities.length > 0 ? ( {activities.length > 0 ? (
<ul className='mx-auto flex max-w-[640px] flex-col'> <ul className='mx-auto flex max-w-[640px] flex-col'>
{inboxTabActivities.reverse().map((activity, index) => ( {activities.map((activity, index) => (
<li <li
key={activity.id} key={activity.id}
data-test-view-article data-test-view-article
onClick={() => handleViewContent(activity.object, activity.actor)} onClick={() => handleViewContent(
activity.object,
activity.actor,
getCommentsForObject(activity.object.id)
)}
> >
<FeedItem <FeedItem
actor={activity.actor} actor={activity.actor}
comments={getCommentsForObject(activity.object.id)}
layout={layout} layout={layout}
object={activity.object} object={activity.object}
type={activity.type} type={activity.type}
/> />
{index < inboxTabActivities.length - 1 && ( {index < activities.length - 1 && (
<div className="h-px w-full bg-grey-200"></div> <div className="h-px w-full bg-grey-200"></div>
)} )}
</li> </li>

View File

@ -1,9 +1,12 @@
import React, {ReactNode} from 'react'; import React, {ReactNode} from 'react';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
export type Activity = { export type Activity = {
type: string, type: string,
object: { actor: ActorProperties,
type: string object: ObjectProperties & {
inReplyTo: string | null // TODO: Move this to the ObjectProperties type
} }
} }

View File

@ -1,15 +1,20 @@
import FeedItem from './FeedItem';
import MainHeader from '../navigation/MainHeader';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useEffect, useRef} from 'react'; import React, {useEffect, useRef} from 'react';
import articleBodyStyles from '../articleBodyStyles';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Modal} from '@tryghost/admin-x-design-system'; import {Button, Modal} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import FeedItem from './FeedItem';
import MainHeader from '../navigation/MainHeader';
import articleBodyStyles from '../articleBodyStyles';
import {type Activity} from '../activities/ActivityItem';
interface ArticleModalProps { interface ArticleModalProps {
object: ObjectProperties; object: ObjectProperties;
actor: ActorProperties; actor: ActorProperties;
comments: Activity[];
} }
const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => { const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => {
@ -63,7 +68,7 @@ ${image &&
); );
}; };
const ArticleModal: React.FC<ArticleModalProps> = ({object, actor}) => { const ArticleModal: React.FC<ArticleModalProps> = ({object, actor, comments}) => {
const modal = useModal(); const modal = useModal();
return ( return (
<Modal <Modal
@ -89,20 +94,37 @@ const ArticleModal: React.FC<ArticleModalProps> = ({object, actor}) => {
<div className='mt-10 w-auto'> <div className='mt-10 w-auto'>
{object.type === 'Note' && ( {object.type === 'Note' && (
<div className='mx-auto max-w-[580px]'> <div className='mx-auto max-w-[580px]'>
<FeedItem actor={actor} layout='modal' object={object} type='Note'/> <FeedItem
actor={actor}
comments={comments}
layout='modal'
object={object}
type='Note'
/>
{/* {object.content && <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>} */} {/* {object.content && <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>} */}
{/* {renderAttachment(object)} */} {/* {renderAttachment(object)} */}
<FeedItem actor={actor} last={false} layout='reply' object={object} type='Note'/> {/* <FeedItem actor={actor} last={false} layout='reply' object={object} type='Note'/>
<FeedItem actor={actor} last={true} layout='reply' object={object} type='Note'/> <FeedItem actor={actor} last={true} layout='reply' object={object} type='Note'/>
<div className="mx-[-32px] my-4 h-px w-[120%] bg-grey-200"></div> <div className="mx-[-32px] my-4 h-px w-[120%] bg-grey-200"></div>
<FeedItem actor={actor} last={false} layout='reply' object={object} type='Note'/> <FeedItem actor={actor} last={false} layout='reply' object={object} type='Note'/>
<FeedItem actor={actor} last={false} layout='reply' object={object} type='Note'/> <FeedItem actor={actor} last={false} layout='reply' object={object} type='Note'/>
<FeedItem actor={actor} last={true} layout='reply' object={object} type='Note'/> <FeedItem actor={actor} last={true} layout='reply' object={object} type='Note'/> */}
{comments.map((comment, index) => (
<FeedItem
actor={comment.actor}
last={index === comments.length - 1}
layout='reply'
object={comment.object}
type='Note'
/>
))}
</div>)} </div>)}
{object.type === 'Article' && <ArticleBody heading={object.name} html={object.content} image={object?.image}/>} {object.type === 'Article' && (
<ArticleBody heading={object.name} html={object.content} image={object?.image} />
)}
</div> </div>
</Modal> </Modal>
); );
}; };
export default NiceModal.create(ArticleModal); export default NiceModal.create(ArticleModal);

View File

@ -1,9 +1,12 @@
import APAvatar from '../global/APAvatar';
import React, {useState} from 'react'; import React, {useState} from 'react';
import getRelativeTimestamp from '../../utils/get-relative-timestamp';
import getUsername from '../../utils/get-username';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Heading, Icon} from '@tryghost/admin-x-design-system'; import {Button, Heading, Icon} from '@tryghost/admin-x-design-system';
import APAvatar from '../global/APAvatar';
import getRelativeTimestamp from '../../utils/get-relative-timestamp';
import getUsername from '../../utils/get-username';
import {type Activity} from '../activities/ActivityItem';
import {useLikeMutationForUser, useUnlikeMutationForUser} from '../../hooks/useActivityPubQueries'; import {useLikeMutationForUser, useUnlikeMutationForUser} from '../../hooks/useActivityPubQueries';
export function renderFeedAttachment(object: ObjectProperties, layout: string) { export function renderFeedAttachment(object: ObjectProperties, layout: string) {
@ -155,13 +158,13 @@ const FeedItemStats: React.FC<{
return (<div className='flex gap-5'> return (<div className='flex gap-5'>
<div className='mt-3 flex gap-1'> <div className='mt-3 flex gap-1'>
<Button <Button
className={`self-start text-grey-900 transition-all hover:opacity-70 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} className={`self-start text-grey-900 transition-all hover:opacity-70 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`}
hideLabel={true} hideLabel={true}
icon='heart' icon='heart'
id='like' id='like'
size='md' size='md'
unstyled={true} unstyled={true}
onClick={(e?: React.MouseEvent<HTMLElement>) => { onClick={(e?: React.MouseEvent<HTMLElement>) => {
e?.stopPropagation(); e?.stopPropagation();
handleLikeClick(); handleLikeClick();
@ -170,13 +173,13 @@ const FeedItemStats: React.FC<{
{isLiked && <span className={`text-grey-900`}>{likeCount}</span>} {isLiked && <span className={`text-grey-900`}>{likeCount}</span>}
</div> </div>
<div className='mt-3 flex gap-1'> <div className='mt-3 flex gap-1'>
<Button <Button
className={`self-start text-grey-900`} className={`self-start text-grey-900`}
hideLabel={true} hideLabel={true}
icon='comment' icon='comment'
id='comment' id='comment'
size='md' size='md'
unstyled={true} unstyled={true}
onClick={(e?: React.MouseEvent<HTMLElement>) => { onClick={(e?: React.MouseEvent<HTMLElement>) => {
e?.stopPropagation(); e?.stopPropagation();
onCommentClick(); onCommentClick();
@ -192,10 +195,11 @@ interface FeedItemProps {
object: ObjectProperties; object: ObjectProperties;
layout: string; layout: string;
type: string; type: string;
comments?: Activity[];
last?: boolean; last?: boolean;
} }
const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, last}) => { const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comments = [], last}) => {
const timestamp = 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'}); 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'});
@ -238,7 +242,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, last})
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div> <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>
{renderFeedAttachment(object, layout)} {renderFeedAttachment(object, layout)}
<FeedItemStats <FeedItemStats
commentCount={2} commentCount={comments.length}
likeCount={1} likeCount={1}
object={object} object={object}
onCommentClick={onLikeClick} onCommentClick={onLikeClick}
@ -283,7 +287,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, last})
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.6rem] text-grey-900'></div> <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.6rem] text-grey-900'></div>
{renderFeedAttachment(object, layout)} {renderFeedAttachment(object, layout)}
<FeedItemStats <FeedItemStats
commentCount={2} commentCount={comments.length}
likeCount={1} likeCount={1}
object={object} object={object}
onCommentClick={onLikeClick} onCommentClick={onLikeClick}
@ -297,7 +301,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, last})
</div> </div>
<div className="mx-[-32px] my-4 h-px w-[120%] bg-grey-200"></div> <div className="mx-[-32px] my-4 h-px w-[120%] bg-grey-200"></div>
</div> </div>
)} )}
</> </>
); );
@ -330,7 +334,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, last})
<div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div> <div dangerouslySetInnerHTML={({__html: object.content})} className='ap-note-content text-pretty text-[1.5rem] text-grey-900'></div>
{renderFeedAttachment(object, layout)} {renderFeedAttachment(object, layout)}
<FeedItemStats <FeedItemStats
commentCount={2} commentCount={comments.length}
likeCount={1} likeCount={1}
object={object} object={object}
onCommentClick={onLikeClick} onCommentClick={onLikeClick}
@ -367,7 +371,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, last})
{renderInboxAttachment(object)} {renderInboxAttachment(object)}
</div> </div>
<FeedItemStats <FeedItemStats
commentCount={2} commentCount={comments.length}
likeCount={1} likeCount={1}
object={object} object={object}
onCommentClick={onLikeClick} onCommentClick={onLikeClick}