mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-08 02:52:39 -05:00
Implemented lazy loading for Inbox & Activities (#21106)
closes https://linear.app/tryghost/issue/AP-421 This makes loading the inbox and activity tabs _way_ faster, so we no longer have to artificially restrict the amount of data coming in, it also gives us proper pagination for both views.
This commit is contained in:
parent
0125f52dc4
commit
8a1e71a553
4 changed files with 222 additions and 69 deletions
|
@ -152,6 +152,54 @@ export class ActivityPubAPI {
|
|||
return new URL(`.ghost/activitypub/activities/${this.handle}`, this.apiUrl);
|
||||
}
|
||||
|
||||
async getActivities(
|
||||
includeOwn: boolean = false,
|
||||
includeReplies: boolean = false,
|
||||
filter: {type?: string[]} | null = null,
|
||||
cursor?: string
|
||||
): Promise<{data: Activity[], nextCursor: string | null}> {
|
||||
const LIMIT = 50;
|
||||
|
||||
const url = new URL(this.activitiesApiUrl);
|
||||
url.searchParams.set('limit', LIMIT.toString());
|
||||
if (includeOwn) {
|
||||
url.searchParams.set('includeOwn', includeOwn.toString());
|
||||
}
|
||||
if (includeReplies) {
|
||||
url.searchParams.set('includeReplies', includeReplies.toString());
|
||||
}
|
||||
if (filter) {
|
||||
url.searchParams.set('filter', JSON.stringify(filter));
|
||||
}
|
||||
if (cursor) {
|
||||
url.searchParams.set('cursor', cursor);
|
||||
}
|
||||
|
||||
const json = await this.fetchJSON(url);
|
||||
|
||||
if (json === null) {
|
||||
return {
|
||||
data: [],
|
||||
nextCursor: null
|
||||
};
|
||||
}
|
||||
|
||||
if (!('items' in json)) {
|
||||
return {
|
||||
data: [],
|
||||
nextCursor: null
|
||||
};
|
||||
}
|
||||
|
||||
const data = Array.isArray(json.items) ? json.items : [];
|
||||
const nextCursor = 'nextCursor' in json && typeof json.nextCursor === 'string' ? json.nextCursor : null;
|
||||
|
||||
return {
|
||||
data,
|
||||
nextCursor
|
||||
};
|
||||
}
|
||||
|
||||
async getAllActivities(
|
||||
includeOwn: boolean = false,
|
||||
includeReplies: boolean = false,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import React from 'react';
|
||||
import {Button, NoValueLabel} from '@tryghost/admin-x-design-system';
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import {Button, LoadingIndicator, NoValueLabel} from '@tryghost/admin-x-design-system';
|
||||
|
||||
import APAvatar, {AvatarBadge} from './global/APAvatar';
|
||||
import ActivityItem, {type Activity} from './activities/ActivityItem';
|
||||
|
@ -8,7 +8,7 @@ import ArticleModal from './feed/ArticleModal';
|
|||
import MainNavigation from './navigation/MainNavigation';
|
||||
|
||||
import getUsername from '../utils/get-username';
|
||||
import {useAllActivitiesForUser, useSiteUrl} from '../hooks/useActivityPubQueries';
|
||||
import {useActivitiesForUser, useSiteUrl} from '../hooks/useActivityPubQueries';
|
||||
import {useFollowersForUser} from '../MainContent';
|
||||
|
||||
interface ActivitiesProps {}
|
||||
|
@ -86,7 +86,12 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
|
|||
const user = 'index';
|
||||
const siteUrl = useSiteUrl();
|
||||
|
||||
const {data: activities = []} = useAllActivitiesForUser({
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage
|
||||
} = useActivitiesForUser({
|
||||
handle: user,
|
||||
includeOwn: true,
|
||||
includeReplies: true,
|
||||
|
@ -95,6 +100,33 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
|
|||
}
|
||||
});
|
||||
|
||||
const activities = (data?.pages.flatMap(page => page.data) ?? []);
|
||||
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
|
||||
observerRef.current = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
});
|
||||
|
||||
if (loadMoreRef.current) {
|
||||
observerRef.current.observe(loadMoreRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
// Retrieve followers for the user
|
||||
const {data: followers = []} = useFollowersForUser(user);
|
||||
|
||||
|
@ -114,40 +146,48 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
|
|||
</div>
|
||||
)}
|
||||
{activities.length > 0 && (
|
||||
<div className='mt-8 flex w-full max-w-[560px] flex-col'>
|
||||
{activities?.map(activity => (
|
||||
<ActivityItem
|
||||
key={activity.id}
|
||||
url={getActivityUrl(activity) || getActorUrl(activity)}
|
||||
onClick={
|
||||
activity.type === ACTVITY_TYPE.CREATE ? () => {
|
||||
NiceModal.show(ArticleModal, {
|
||||
object: activity.object,
|
||||
actor: activity.actor,
|
||||
comments: activity.object.replies
|
||||
});
|
||||
} : undefined
|
||||
}
|
||||
>
|
||||
<APAvatar author={activity.actor} badge={getActivityBadge(activity)} />
|
||||
<div className='pt-[2px]'>
|
||||
<div className='text-grey-600'>
|
||||
<span className='mr-1 font-bold text-black'>{activity.actor.name}</span>
|
||||
{getUsername(activity.actor)}
|
||||
<>
|
||||
<div className='mt-8 flex w-full max-w-[560px] flex-col'>
|
||||
{activities?.map(activity => (
|
||||
<ActivityItem
|
||||
key={activity.id}
|
||||
url={getActivityUrl(activity) || getActorUrl(activity)}
|
||||
onClick={
|
||||
activity.type === ACTVITY_TYPE.CREATE ? () => {
|
||||
NiceModal.show(ArticleModal, {
|
||||
object: activity.object,
|
||||
actor: activity.actor,
|
||||
comments: activity.object.replies
|
||||
});
|
||||
} : undefined
|
||||
}
|
||||
>
|
||||
<APAvatar author={activity.actor} badge={getActivityBadge(activity)} />
|
||||
<div className='pt-[2px]'>
|
||||
<div className='text-grey-600'>
|
||||
<span className='mr-1 font-bold text-black'>{activity.actor.name}</span>
|
||||
{getUsername(activity.actor)}
|
||||
</div>
|
||||
<div className=''>{getActivityDescription(activity)}</div>
|
||||
{getExtendedDescription(activity)}
|
||||
</div>
|
||||
<div className=''>{getActivityDescription(activity)}</div>
|
||||
{getExtendedDescription(activity)}
|
||||
</div>
|
||||
{isFollower(activity.actor.id) === false && (
|
||||
<Button className='ml-auto' label='Follow' link onClick={(e) => {
|
||||
e?.preventDefault();
|
||||
{isFollower(activity.actor.id) === false && (
|
||||
<Button className='ml-auto' label='Follow' link onClick={(e) => {
|
||||
e?.preventDefault();
|
||||
|
||||
alert('Implement me!');
|
||||
}} />
|
||||
)}
|
||||
</ActivityItem>
|
||||
))}
|
||||
</div>
|
||||
alert('Implement me!');
|
||||
}} />
|
||||
)}
|
||||
</ActivityItem>
|
||||
))}
|
||||
</div>
|
||||
<div ref={loadMoreRef} className='h-1'></div>
|
||||
{isFetchingNextPage && (
|
||||
<div className='flex flex-col items-center justify-center space-y-4 text-center'>
|
||||
<LoadingIndicator size='md' />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -3,11 +3,11 @@ import ArticleModal from './feed/ArticleModal';
|
|||
import FeedItem from './feed/FeedItem';
|
||||
import MainNavigation from './navigation/MainNavigation';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import React, {useState} from 'react';
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {type Activity} from './activities/ActivityItem';
|
||||
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {Button, Heading} from '@tryghost/admin-x-design-system';
|
||||
import {useAllActivitiesForUser} from '../hooks/useActivityPubQueries';
|
||||
import {Button, Heading, LoadingIndicator} from '@tryghost/admin-x-design-system';
|
||||
import {useActivitiesForUser} from '../hooks/useActivityPubQueries';
|
||||
|
||||
interface InboxProps {}
|
||||
|
||||
|
@ -16,8 +16,12 @@ const Inbox: React.FC<InboxProps> = ({}) => {
|
|||
const [, setArticleActor] = useState<ActorProperties | null>(null);
|
||||
const [layout, setLayout] = useState('inbox');
|
||||
|
||||
// Retrieve all activities for the user
|
||||
const {data: activities = []} = useAllActivitiesForUser({
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage
|
||||
} = useActivitiesForUser({
|
||||
handle: 'index',
|
||||
includeReplies: true,
|
||||
filter: {
|
||||
|
@ -25,6 +29,8 @@ const Inbox: React.FC<InboxProps> = ({}) => {
|
|||
}
|
||||
});
|
||||
|
||||
const activities = (data?.pages.flatMap(page => page.data) ?? []);
|
||||
|
||||
const handleViewContent = (object: ObjectProperties, actor: ActorProperties, comments: Activity[], focusReply = false) => {
|
||||
setArticleContent(object);
|
||||
setArticleActor(actor);
|
||||
|
@ -59,42 +65,77 @@ const Inbox: React.FC<InboxProps> = ({}) => {
|
|||
setLayout(newLayout);
|
||||
};
|
||||
|
||||
// Intersection observer to fetch more activities when the user scrolls
|
||||
// to the bottom of the page
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
|
||||
observerRef.current = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
});
|
||||
|
||||
if (loadMoreRef.current) {
|
||||
observerRef.current.observe(loadMoreRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MainNavigation page='home' title="Home" onLayoutChange={handleLayoutChange} />
|
||||
<div className='z-0 my-5 flex w-full flex-col'>
|
||||
<div className='w-full'>
|
||||
{activities.length > 0 ? (
|
||||
<ul className='mx-auto flex max-w-[640px] flex-col'>
|
||||
{activities.map((activity, index) => (
|
||||
<li
|
||||
key={activity.id}
|
||||
data-test-view-article
|
||||
onClick={() => handleViewContent(
|
||||
activity.object,
|
||||
getContentAuthor(activity),
|
||||
activity.object.replies
|
||||
)}
|
||||
>
|
||||
<FeedItem
|
||||
actor={activity.actor}
|
||||
comments={activity.object.replies}
|
||||
layout={layout}
|
||||
object={activity.object}
|
||||
type={activity.type}
|
||||
onCommentClick={() => handleViewContent(
|
||||
<>
|
||||
<ul className='mx-auto flex max-w-[640px] flex-col'>
|
||||
{activities.map((activity, index) => (
|
||||
<li
|
||||
key={activity.id}
|
||||
data-test-view-article
|
||||
onClick={() => handleViewContent(
|
||||
activity.object,
|
||||
getContentAuthor(activity),
|
||||
activity.object.replies,
|
||||
true
|
||||
activity.object.replies
|
||||
)}
|
||||
/>
|
||||
{index < activities.length - 1 && (
|
||||
<div className="h-px w-full bg-grey-200"></div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
>
|
||||
<FeedItem
|
||||
actor={activity.actor}
|
||||
comments={activity.object.replies}
|
||||
layout={layout}
|
||||
object={activity.object}
|
||||
type={activity.type}
|
||||
onCommentClick={() => handleViewContent(
|
||||
activity.object,
|
||||
getContentAuthor(activity),
|
||||
activity.object.replies,
|
||||
true
|
||||
)}
|
||||
/>
|
||||
{index < activities.length - 1 && (
|
||||
<div className="h-px w-full bg-grey-200"></div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div ref={loadMoreRef} className='h-1'></div>
|
||||
{isFetchingNextPage && (
|
||||
<div className='flex flex-col items-center justify-center space-y-4 text-center'>
|
||||
<LoadingIndicator size='md' />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className='flex items-center justify-center text-center'>
|
||||
<div className='flex max-w-[32em] flex-col items-center justify-center gap-4'>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {Activity} from '../components/activities/ActivityItem';
|
||||
import {ActivityPubAPI} from '../api/activitypub';
|
||||
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
|
||||
export function useSiteUrl() {
|
||||
const site = useBrowseSite();
|
||||
|
@ -207,3 +207,27 @@ export function useAllActivitiesForUser({
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useActivitiesForUser({
|
||||
handle,
|
||||
includeOwn = false,
|
||||
includeReplies = false,
|
||||
filter = null
|
||||
}: {
|
||||
handle: string;
|
||||
includeOwn?: boolean;
|
||||
includeReplies?: boolean;
|
||||
filter?: {type?: string[]} | null;
|
||||
}) {
|
||||
const siteUrl = useSiteUrl();
|
||||
const api = createActivityPubAPI(handle, siteUrl);
|
||||
return useInfiniteQuery({
|
||||
queryKey: [`activities:${JSON.stringify({handle, includeOwn, includeReplies, filter})}`],
|
||||
async queryFn({pageParam}: {pageParam?: string}) {
|
||||
return api.getActivities(includeOwn, includeReplies, filter, pageParam);
|
||||
},
|
||||
getNextPageParam(prevPage) {
|
||||
return prevPage.nextCursor;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue