0
Fork 0
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:
Fabien 'egg' O'Carroll 2024-09-25 12:20:07 +07:00 committed by GitHub
parent 0125f52dc4
commit 8a1e71a553
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 222 additions and 69 deletions

View file

@ -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,

View file

@ -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>
</>

View file

@ -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'>

View file

@ -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;
}
});
}