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:
parent
ea6d3a0f26
commit
5f59ddaacc
9 changed files with 227 additions and 109 deletions
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@tryghost/admin-x-activitypub",
|
||||
"version": "0.3.4",
|
||||
"version": "0.3.5",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 />}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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};
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue