0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-25 02:31:59 -05:00

Updated replies implementation to use thread mechanism in admin-x-activitypub (#21465)

refs:

- https://linear.app/ghost/issue/AP-439/seeing-parent-post-for-replies
- https://linear.app/ghost/issue/AP-481/reply-ui
-
https://linear.app/ghost/issue/AP-482/replies-to-a-post-should-be-visible-when-opening-it

Updated the replies implementation to make use of the new thread
mechanism. This allows for a more sane approach to handling replies as
well as making it possible to show the parent of a reply in the UI

---------

Co-authored-by: Djordje Vlaisavljevic <dzvlais@gmail.com>
This commit is contained in:
Michael Barrett 2024-10-31 09:38:52 +00:00 committed by GitHub
parent ea6d3a0f26
commit 5f59ddaacc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 227 additions and 109 deletions

View file

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

View file

@ -32,6 +32,10 @@ export interface GetFollowingForProfileResponse {
next: string | null;
}
export interface ActivityThread {
items: Activity[];
}
export class ActivityPubAPI {
constructor(
private readonly apiUrl: URL,
@ -509,4 +513,10 @@ export class ActivityPubAPI {
const json = await this.fetchJSON(url);
return json as Profile;
}
async getThread(id: string): Promise<ActivityThread> {
const url = new URL(`.ghost/activitypub/thread/${btoa(id)}`, this.apiUrl);
const json = await this.fetchJSON(url);
return json as ActivityThread;
}
}

View file

@ -91,13 +91,7 @@ const getActivityBadge = (activity: Activity): AvatarBadge => {
const Activities: React.FC<ActivitiesProps> = ({}) => {
const user = 'index';
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading
} = useActivitiesForUser({
const {getActivitiesQuery} = useActivitiesForUser({
handle: user,
includeOwn: true,
includeReplies: true,
@ -105,7 +99,7 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
type: ['Follow', 'Like', `Create:Note:isReplyToOwn`]
}
});
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = getActivitiesQuery;
const activities = (data?.pages.flatMap(page => page.data) ?? []);
const observerRef = useRef<IntersectionObserver | null>(null);
@ -144,16 +138,16 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
switch (activity.type) {
case ACTVITY_TYPE.CREATE:
NiceModal.show(ArticleModal, {
activityId: activity.id,
object: activity.object,
actor: activity.actor,
comments: activity.object.replies
actor: activity.actor
});
break;
case ACTVITY_TYPE.LIKE:
NiceModal.show(ArticleModal, {
activityId: activity.id,
object: activity.object,
actor: activity.object.attributedTo as ActorProperties,
comments: activity.object.replies
actor: activity.object.attributedTo as ActorProperties
});
break;
case ACTVITY_TYPE.FOLLOW:

View file

@ -21,20 +21,14 @@ const Inbox: React.FC<InboxProps> = ({}) => {
const [, setArticleActor] = useState<ActorProperties | null>(null);
const {layout, setFeed, setInbox} = useLayout();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading
} = useActivitiesForUser({
const {getActivitiesQuery, updateActivity} = useActivitiesForUser({
handle: 'index',
includeReplies: true,
excludeNonFollowers: true,
filter: {
type: ['Create:Article:notReply', 'Create:Note:notReply', 'Announce:Note']
type: ['Create:Article', 'Create:Note', 'Announce:Note']
}
});
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = getActivitiesQuery;
const {updateRoute} = useRouting();
@ -45,10 +39,16 @@ const Inbox: React.FC<InboxProps> = ({}) => {
return !activity.object.inReplyTo;
});
const handleViewContent = (object: ObjectProperties, actor: ActorProperties, comments: Activity[], focusReply = false) => {
const handleViewContent = (activityId: string, object: ObjectProperties, actor: ActorProperties, focusReply = false) => {
setArticleContent(object);
setArticleActor(actor);
NiceModal.show(ArticleModal, {object, actor, comments, focusReply});
NiceModal.show(ArticleModal, {
activityId,
object,
actor,
focusReply,
updateActivity
});
};
function getContentAuthor(activity: Activity) {
@ -121,21 +121,21 @@ const Inbox: React.FC<InboxProps> = ({}) => {
key={activity.id}
data-test-view-article
onClick={() => handleViewContent(
activity.id,
activity.object,
getContentAuthor(activity),
activity.object.replies
getContentAuthor(activity)
)}
>
<FeedItem
actor={activity.actor}
comments={activity.object.replies}
commentCount={activity.object.replyCount ?? 0}
layout={layout}
object={activity.object}
type={activity.type}
onCommentClick={() => handleViewContent(
activity.id,
activity.object,
getContentAuthor(activity),
activity.object.replies,
true
)}
/>

View file

@ -3,11 +3,13 @@ import React, {ReactNode} from 'react';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
export type Activity = {
id: string,
type: string,
actor: ActorProperties,
object: ObjectProperties & {
inReplyTo: ObjectProperties | string | null
replies: Activity[]
replyCount: number
}
}

View file

@ -2,20 +2,28 @@ 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, Modal} from '@tryghost/admin-x-design-system';
import {Button, LoadingIndicator, Modal} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import FeedItem from './FeedItem';
import APReplyBox from '../global/APReplyBox';
import articleBodyStyles from '../articleBodyStyles';
import {type Activity} from '../activities/ActivityItem';
import APReplyBox from '../global/APReplyBox';
import FeedItem from './FeedItem';
import articleBodyStyles from '../articleBodyStyles';
import {useThreadForUser} from '../../hooks/useActivityPubQueries';
interface ArticleModalProps {
activityId: string;
object: ObjectProperties;
actor: ActorProperties;
comments: Activity[];
focusReply: boolean;
updateActivity: (id: string, updated: Partial<Activity>) => void;
history: {
activityId: string;
object: ObjectProperties;
actor: ActorProperties;
}[];
}
const ArticleBody: React.FC<{heading: string, image: string|undefined, excerpt: string|undefined, html: string}> = ({heading, image, excerpt, html}) => {
@ -146,9 +154,15 @@ const FeedItemDivider: React.FC = () => (
<div className="h-px bg-grey-200"></div>
);
const ArticleModal: React.FC<ArticleModalProps> = ({object, actor, comments, focusReply}) => {
const ArticleModal: React.FC<ArticleModalProps> = ({
activityId,
object,
actor,
focusReply,
updateActivity = () => {},
history = []
}) => {
const MODAL_SIZE_SM = 640;
const [commentsState, setCommentsState] = useState(comments);
const [isFocused, setFocused] = useState(focusReply ? 1 : 0);
function setReplyBoxFocused(focused: boolean) {
if (focused) {
@ -158,43 +172,69 @@ const ArticleModal: React.FC<ArticleModalProps> = ({object, actor, comments, foc
}
}
const {threadQuery, addToThread} = useThreadForUser('index', activityId);
const {data: activityThread, isLoading: isLoadingThread} = threadQuery;
const activtyThreadActivityIdx = (activityThread?.items ?? []).findIndex(item => item.id === activityId);
const activityThreadChildren = (activityThread?.items ?? []).slice(activtyThreadActivityIdx + 1);
const activityThreadParents = (activityThread?.items ?? []).slice(0, activtyThreadActivityIdx);
const [modalSize] = useState<number>(MODAL_SIZE_SM);
const modal = useModal();
// Navigation stack to navigate between comments - This could probably use a
// more robust solution, but for now, thanks to the fact modal.show() updates
// the existing modal instead of creating a new one (i think 😅) we can use
// a stack to navigate between comments pretty easily
//
// @TODO: Look into a more robust solution for navigation
const [navigationStack, setNavigationStack] = useState<[ObjectProperties, ActorProperties, Activity[]][]>([]);
const [canNavigateBack, setCanNavigateBack] = useState(false);
const canNavigateBack = history.length > 0;
const navigateBack = () => {
const [previousObject, previousActor, previousComments] = navigationStack.pop() ?? [];
const prevProps = history.pop();
if (navigationStack.length === 0) {
setCanNavigateBack(false);
// This shouldn't happen, but if it does, just remove the modal
if (!prevProps) {
modal.remove();
return;
}
modal.show({
object: previousObject,
actor: previousActor,
comments: previousComments
activityId: prevProps.activityId,
object: prevProps.object,
actor: prevProps.actor,
updateActivity,
history
});
};
const navigateForward = (nextObject: ObjectProperties, nextActor: ActorProperties, nextComments: Activity[]) => {
setCanNavigateBack(true);
setNavigationStack([...navigationStack, [object, actor, commentsState]]);
const navigateForward = (nextActivityId: string, nextObject: ObjectProperties, nextActor: ActorProperties) => {
// Trigger the modal to show the next activity and add the existing
// activity to the history so we can navigate back
modal.show({
activityId: nextActivityId,
object: nextObject,
actor: nextActor,
comments: nextComments
updateActivity,
history: [
...history,
{
activityId: activityId,
object: object,
actor: actor
}
]
});
};
function handleNewReply(activity: Activity) {
setCommentsState(prev => [activity].concat(prev));
// Add the new reply to the thread
addToThread(activity);
// Update the replyCount on the activity outside of the context
// of this component
updateActivity(activityId, {
object: {
...object,
replyCount: object.replyCount + 1
}
} as Partial<Activity>);
// Update the replyCount on the current activity loaded in the modal
// This is used for when we navigate via the history
object.replyCount = object.replyCount + 1;
}
return (
@ -223,62 +263,79 @@ const ArticleModal: React.FC<ArticleModalProps> = ({object, actor, comments, foc
</div>
</div>
<div className='grow overflow-y-auto'>
<div className='mx-auto max-w-[580px] py-10'>
<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={() => {}}
/>
)}
</>
);
})}
{object.type === 'Note' && (
<FeedItem
actor={actor}
comments={comments}
layout='modal'
commentCount={object.replyCount ?? 0}
last={true}
layout={activityThreadParents.length > 0 ? 'modal' : 'modal'}
object={object}
type='Note'
onCommentClick={() => {
setReplyBoxFocused(true);
}}
/>)}
{object.type === 'Article' && (
<ArticleBody excerpt={object?.preview?.content} heading={object.name} html={object.content} image={object?.image} />
/>
)}
{object.type === 'Article' && (
<ArticleBody
excerpt={object?.preview?.content}
heading={object.name}
html={object.content}
image={object?.image}
/>
)}
<APReplyBox focused={isFocused} object={object} onNewReply={handleNewReply}/>
<FeedItemDivider />
{commentsState.map((comment, index) => {
const showDivider = index !== comments.length - 1;
const nestedComments = comment.object.replies ?? [];
const hasNestedComments = nestedComments.length > 0;
{isLoadingThread && <LoadingIndicator size='lg' />}
{activityThreadChildren.map((item, index) => {
const showDivider = index !== activityThreadChildren.length - 1;
return (
<>
<FeedItem
actor={comment.actor}
comments={nestedComments}
actor={item.actor}
commentCount={item.object.replyCount ?? 0}
last={true}
layout='reply'
object={comment.object}
object={item.object}
type='Note'
onClick={() => {
navigateForward(comment.object, comment.actor, nestedComments);
navigateForward(item.id, item.object, item.actor);
}}
onCommentClick={() => {}}
/>
{hasNestedComments && <FeedItemDivider />}
{nestedComments.map((nestedComment, nestedCommentIndex) => {
const nestedNestedComments = nestedComment.object.replies ?? [];
return (
<FeedItem
actor={nestedComment.actor}
comments={nestedNestedComments}
last={nestedComments.length === nestedCommentIndex + 1}
layout='reply'
object={nestedComment.object}
type='Note'
onClick={() => {
navigateForward(nestedComment.object, nestedComment.actor, nestedNestedComments);
}}
onCommentClick={() => {}}
/>
);
})}
{showDivider && <FeedItemDivider />}
</>
);

View file

@ -7,7 +7,6 @@ import APAvatar from '../global/APAvatar';
import getRelativeTimestamp from '../../utils/get-relative-timestamp';
import getUsername from '../../utils/get-username';
import stripHtml from '../../utils/strip-html';
import {type Activity} from '../activities/ActivityItem';
import {useLikeMutationForUser, useUnlikeMutationForUser} from '../../hooks/useActivityPubQueries';
function getAttachment(object: ObjectProperties) {
@ -203,7 +202,7 @@ const FeedItemStats: React.FC<{
/>
{isLiked && (layout !== 'inbox') && <span className={`text-grey-900`}>{new Intl.NumberFormat().format(likeCount)}</span>}
</div>
{(layout !== 'modal') && (<div className='flex gap-1'>
<div className='flex gap-1'>
<Button
className={`self-start text-grey-900 hover:opacity-60 ${isClicked ? 'bump' : ''}`}
hideLabel={true}
@ -219,7 +218,7 @@ const FeedItemStats: React.FC<{
{commentCount > 0 && (layout !== 'inbox') && (
<span className={`text-grey-900`}>{new Intl.NumberFormat().format(commentCount)}</span>
)}
</div>)}
</div>
</div>);
};
@ -228,7 +227,7 @@ interface FeedItemProps {
object: ObjectProperties;
layout: string;
type: string;
comments?: Activity[];
commentCount?: number;
last?: boolean;
onClick?: () => void;
onCommentClick: () => void;
@ -236,7 +235,7 @@ interface FeedItemProps {
const noop = () => {};
const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comments = [], last, onClick = noop, onCommentClick}) => {
const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, commentCount = 0, 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'});
@ -302,7 +301,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
return (
<>
{object && (
<div className={`group/article relative cursor-pointer pt-6`} onClick={onClick}>
<div className={`group/article relative cursor-pointer pt-6`} data-layout='feed' onClick={onClick}>
{(type === 'Announce' && object.type === 'Note') && <div className='z-10 mb-2 flex items-center gap-3 text-grey-700'>
<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>
@ -338,7 +337,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
</div>
<div className='space-between relative z-[30] mt-5 flex'>
<FeedItemStats
commentCount={comments.length}
commentCount={commentCount}
layout={layout}
likeCount={1}
object={object}
@ -359,12 +358,12 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
<>
{object && (
<div>
<div className={`group/article relative`} onClick={onClick}>
<div className={`group/article relative`} data-layout='modal' onClick={onClick}>
{(type === 'Announce' && object.type === 'Note') && <div className='z-10 mb-2 flex items-center gap-3 text-grey-700'>
<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={`z-10 -my-1 grid grid-cols-[auto_1fr] grid-rows-[auto_1fr] gap-3 pb-4`} data-test-activity>
<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]'>
<APAvatar author={author}/>
</div>
@ -384,7 +383,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
{renderFeedAttachment(object, layout)}
<div className='space-between mt-5 flex'>
<FeedItemStats
commentCount={comments.length}
commentCount={commentCount}
layout={layout}
likeCount={1}
object={object}
@ -408,7 +407,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
return (
<>
{object && (
<div className={`group/article relative cursor-pointer py-5`} onClick={onClick}>
<div className={`group/article relative cursor-pointer py-5`} data-layout='reply' onClick={onClick}>
{(type === 'Announce' && object.type === 'Note') && <div className='z-10 mb-2 flex items-center gap-3 text-grey-700'>
<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>
@ -433,7 +432,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
{renderFeedAttachment(object, layout)}
<div className='space-between mt-5 flex'>
<FeedItemStats
commentCount={comments.length}
commentCount={commentCount}
layout={layout}
likeCount={1}
object={object}
@ -455,7 +454,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
return (
<>
{object && (
<div className='group/article relative -mx-4 -my-px flex min-w-0 cursor-pointer justify-between rounded-md p-4 hover:bg-grey-75' onClick={onClick}>
<div className='group/article relative -mx-4 -my-px flex min-w-0 cursor-pointer justify-between rounded-md p-4 hover:bg-grey-75' data-layout='inbox' onClick={onClick}>
<div className='flex w-full min-w-0 flex-col items-start justify-between gap-1 pr-4'>
<div className='z-10 flex w-full min-w-0 items-start gap-2 group-hover/article:border-transparent'>
<APAvatar author={author} size='xs'/>
@ -470,8 +469,8 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
<Heading className='line-clamp-1 font-semibold leading-normal' level={5} data-test-activity-heading>
{object.name ? object.name : (
<span dangerouslySetInnerHTML={{
__html: object.content.length > 30
? stripHtml(object.content).substring(0, 50) + '...'
__html: object.content.length > 30
? stripHtml(object.content).substring(0, 50) + '...'
: stripHtml(object.content)
}}></span>
)}
@ -481,7 +480,7 @@ const FeedItem: React.FC<FeedItemProps> = ({actor, object, layout, type, comment
{renderInboxAttachment(object)}
<div className='invisible absolute right-2 top-[9px] z-[49] flex flex-col gap-2 rounded-lg bg-white p-2 shadow-md-heavy group-hover/article:visible'>
<FeedItemStats
commentCount={comments.length}
commentCount={commentCount}
layout={layout}
likeCount={1}
object={object}

View file

@ -180,7 +180,7 @@ const ViewProfileModal: React.FC<ViewProfileModalProps> = ({
<div>
<FeedItem
actor={profile.actor}
comments={post.object.replies}
commentCount={post.object.replyCount}
layout='feed'
object={post.object}
type={post.type}

View file

@ -1,5 +1,5 @@
import {Activity} from '../components/activities/ActivityItem';
import {ActivityPubAPI, type Profile, type SearchResults} from '../api/activitypub';
import {ActivityPubAPI, ActivityThread, type Profile, type SearchResults} from '../api/activitypub';
import {useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
let SITE_URL: string;
@ -227,8 +227,11 @@ export function useActivitiesForUser({
excludeNonFollowers?: boolean;
filter?: {type?: string[]} | null;
}) {
return useInfiniteQuery({
queryKey: [`activities:${JSON.stringify({handle, includeOwn, includeReplies, filter})}`],
const queryKey = [`activities:${JSON.stringify({handle, includeOwn, includeReplies, filter})}`];
const queryClient = useQueryClient();
const getActivitiesQuery = useInfiniteQuery({
queryKey,
async queryFn({pageParam}: {pageParam?: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
@ -238,6 +241,31 @@ export function useActivitiesForUser({
return prevPage.nextCursor;
}
});
const updateActivity = (id: string, updated: Partial<Activity>) => {
queryClient.setQueryData(queryKey, (current: {pages: {data: Activity[]}[]} | undefined) => {
if (!current) {
return current;
}
return {
...current,
pages: current.pages.map((page: {data: Activity[]}) => {
return {
...page,
data: page.data.map((item: Activity) => {
if (item.id === id) {
return {...item, ...updated};
}
return item;
})
};
})
};
});
};
return {getActivitiesQuery, updateActivity};
}
export function useSearchForUser(handle: string, query: string) {
@ -374,3 +402,31 @@ export function useOutboxForUser(handle: string) {
}
});
}
export function useThreadForUser(handle: string, id: string) {
const queryClient = useQueryClient();
const queryKey = ['thread', {id}];
const threadQuery = useQuery({
queryKey,
async queryFn() {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getThread(id);
}
});
const addToThread = (activity: Activity) => {
queryClient.setQueryData(queryKey, (current: ActivityThread | undefined) => {
if (!current) {
return current;
}
return {
items: [...current.items, activity]
};
});
};
return {threadQuery, addToThread};
}