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

Refactored data loading to be more performant in admin-x-activitypub (#21770)

refs
[AP-614](https://linear.app/ghost/issue/AP-614/investigate-client-loading-issues)

Refactored data loading to be more performant in admin-x-activitypub:
- Utilised tanstack query for suggested profiles instead of rolling our
own custom hook. This allows us to utilise tanstack for caching
- Refactored how data is loaded for the Inbox / Feed view - Queries are
no longer cleared when switching between the 2 layouts
- Refactored how activity data is key'd to make caching more
deterministic as well as allow mutating a specific activity cache (i.e
when adding a note, ensure it only gets added the the feed activities)
- Removed redundant `excludeNonFollowers` parameter - This is no longer
used by the API
This commit is contained in:
Michael Barrett 2024-11-30 22:41:51 +00:00 committed by GitHub
parent 995edaa966
commit 04ed106c73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 65 additions and 144 deletions

View file

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

View file

@ -10,17 +10,14 @@ const MainContent = () => {
switch (mainRoute) {
case 'search':
return <Search />;
break;
case 'activity':
return <Activities />;
break;
case 'profile':
return <Profile />;
break;
default:
const layout = (mainRoute === 'inbox' || mainRoute === '') ? 'inbox' : 'feed';
return <Inbox layout={layout} />;
break;
}
};

View file

@ -213,7 +213,6 @@ export class ActivityPubAPI {
async getActivities(
includeOwn: boolean = false,
includeReplies: boolean = false,
excludeNonFollowers: boolean = false,
filter: {type?: string[]} | null = null,
cursor?: string
): Promise<{data: Activity[], next: string | null}> {
@ -227,9 +226,6 @@ export class ActivityPubAPI {
if (includeReplies) {
url.searchParams.set('includeReplies', includeReplies.toString());
}
if (excludeNonFollowers) {
url.searchParams.set('excludeNonFollowers', excludeNonFollowers.toString());
}
if (filter) {
url.searchParams.set('filter', JSON.stringify(filter));
}

View file

@ -13,7 +13,7 @@ import ViewProfileModal from './modals/ViewProfileModal';
import getUsername from '../utils/get-username';
import stripHtml from '../utils/strip-html';
import {useActivitiesForUser} from '../hooks/useActivityPubQueries';
import {GET_ACTIVITIES_QUERY_KEY_NOTIFICATIONS, useActivitiesForUser} from '../hooks/useActivityPubQueries';
interface ActivitiesProps {}
@ -97,10 +97,13 @@ const Activities: React.FC<ActivitiesProps> = ({}) => {
includeReplies: true,
filter: {
type: ['Follow', 'Like', `Create:Note:isReplyToOwn`]
}
},
key: GET_ACTIVITIES_QUERY_KEY_NOTIFICATIONS
});
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = getActivitiesQuery;
const activities = (data?.pages.flatMap(page => page.data) ?? []);
const activities = (data?.pages.flatMap(page => page.data) ?? [])
// If there somehow are duplicate activities, filter them out so the list rendering doesn't break
.filter((activity, index, self) => index === self.findIndex(a => a.id === activity.id));
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);

View file

@ -10,11 +10,16 @@ import Separator from './global/Separator';
import ViewProfileModal from './modals/ViewProfileModal';
import getName from '../utils/get-name';
import getUsername from '../utils/get-username';
import useSuggestedProfiles from '../hooks/useSuggestedProfiles';
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, Heading, LoadingIndicator} from '@tryghost/admin-x-design-system';
import {
GET_ACTIVITIES_QUERY_KEY_FEED,
GET_ACTIVITIES_QUERY_KEY_INBOX,
useActivitiesForUser,
useSuggestedProfiles,
useUserDataForUser
} from '../hooks/useActivityPubQueries';
import {handleViewContent} from '../utils/content-handlers';
import {useActivitiesForUser, useUserDataForUser} from '../hooks/useActivityPubQueries';
import {useRouting} from '@tryghost/admin-x-framework/routing';
type Layout = 'inbox' | 'feed';
@ -24,6 +29,9 @@ interface InboxProps {
}
const Inbox: React.FC<InboxProps> = ({layout}) => {
const {updateRoute} = useRouting();
// Initialise activities for the inbox or feed
const typeFilter = layout === 'inbox'
? ['Create:Article']
: ['Create:Note', 'Announce:Note'];
@ -31,20 +39,26 @@ const Inbox: React.FC<InboxProps> = ({layout}) => {
const {getActivitiesQuery, updateActivity} = useActivitiesForUser({
handle: 'index',
includeOwn: true,
excludeNonFollowers: true,
filter: {
type: typeFilter
}
},
key: layout === 'inbox' ? GET_ACTIVITIES_QUERY_KEY_INBOX : GET_ACTIVITIES_QUERY_KEY_FEED
});
const {data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading} = getActivitiesQuery;
const {updateRoute} = useRouting();
const activities = (data?.pages.flatMap(page => page.data) ?? [])
// If there somehow are duplicate activities, filter them out so the list rendering doesn't break
.filter((activity, index, self) => index === self.findIndex(a => a.id === activity.id))
// Filter out replies
.filter((activity) => {
return !activity.object.inReplyTo;
});
const {suggested, isLoadingSuggested} = useSuggestedProfiles();
const activities = (data?.pages.flatMap(page => page.data) ?? []).filter((activity) => {
return !activity.object.inReplyTo;
});
// Initialise suggested profiles
const {suggestedProfilesQuery} = useSuggestedProfiles('index', 3);
const {data: suggestedData, isLoading: isLoadingSuggested} = suggestedProfilesQuery;
const suggested = suggestedData || [];
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);

View file

@ -13,8 +13,8 @@ import NiceModal from '@ebay/nice-modal-react';
import ViewProfileModal from './modals/ViewProfileModal';
import Separator from './global/Separator';
import useSuggestedProfiles from '../hooks/useSuggestedProfiles';
import {useSearchForUser} from '../hooks/useActivityPubQueries';
import {useSearchForUser, useSuggestedProfiles} from '../hooks/useActivityPubQueries';
interface SearchResultItem {
actor: ActorProperties;
@ -122,7 +122,9 @@ interface SearchProps {}
const Search: React.FC<SearchProps> = ({}) => {
// Initialise suggested profiles
const {suggested, isLoadingSuggested, updateSuggestedProfile} = useSuggestedProfiles(6);
const {suggestedProfilesQuery, updateSuggestedProfile} = useSuggestedProfiles('index', 6);
const {data: suggestedData, isLoading: isLoadingSuggested} = suggestedProfilesQuery;
const suggested = suggestedData || [];
// Initialise search query
const queryInputRef = useRef<HTMLInputElement>(null);

View file

@ -1,7 +1,6 @@
import MainHeader from './MainHeader';
import React from 'react';
import {Button} from '@tryghost/admin-x-design-system';
import {useQueryClient} from '@tanstack/react-query';
import {useRouting} from '@tryghost/admin-x-framework/routing';
interface MainNavigationProps {
@ -10,15 +9,6 @@ interface MainNavigationProps {
const MainNavigation: React.FC<MainNavigationProps> = ({page}) => {
const {updateRoute} = useRouting();
const queryClient = useQueryClient();
const handleRouteChange = (newRoute: string) => {
queryClient.removeQueries({
queryKey: ['activities:index']
});
updateRoute(newRoute);
};
return (
<MainHeader>
@ -27,13 +17,13 @@ const MainNavigation: React.FC<MainNavigationProps> = ({page}) => {
className={`${page === 'inbox' ? 'font-bold text-grey-975' : 'text-grey-700 hover:text-grey-800'}`}
label='Inbox'
unstyled
onClick={() => handleRouteChange('inbox')}
onClick={() => updateRoute('inbox')}
/>
<Button
className={`${page === 'feed' ? 'font-bold text-grey-975' : 'text-grey-700 hover:text-grey-800'}`}
label='Feed'
unstyled
onClick={() => handleRouteChange('feed')}
onClick={() => updateRoute('feed')}
/>
<Button className={`${page === 'activities' ? 'font-bold text-grey-975' : 'text-grey-700 hover:text-grey-800'}`} label='Notifications' unstyled onClick={() => updateRoute('activity')} />
<Button className={`${page === 'search' ? 'font-bold text-grey-975' : 'text-grey-700 hover:text-grey-800'}`} label='Search' unstyled onClick={() => updateRoute('search')} />

View file

@ -258,20 +258,24 @@ export function useFollow(handle: string, onSuccess: () => void, onError: () =>
});
}
export const GET_ACTIVITIES_QUERY_KEY_INBOX = 'inbox';
export const GET_ACTIVITIES_QUERY_KEY_FEED = 'feed';
export const GET_ACTIVITIES_QUERY_KEY_NOTIFICATIONS = 'notifications';
export function useActivitiesForUser({
handle,
includeOwn = false,
includeReplies = false,
excludeNonFollowers = false,
filter = null
filter = null,
key = null
}: {
handle: string;
includeOwn?: boolean;
includeReplies?: boolean;
excludeNonFollowers?: boolean;
filter?: {type?: string[]} | null;
key?: string | null;
}) {
const queryKey = [`activities:${handle}`, {includeOwn, includeReplies, filterTypes: filter?.type}];
const queryKey = [`activities:${handle}`, key, {includeOwn, includeReplies, filter}];
const queryClient = useQueryClient();
const getActivitiesQuery = useInfiniteQuery({
@ -279,7 +283,7 @@ export function useActivitiesForUser({
async queryFn({pageParam}: {pageParam?: string}) {
const siteUrl = await getSiteUrl();
const api = createActivityPubAPI(handle, siteUrl);
return api.getActivities(includeOwn, includeReplies, excludeNonFollowers, filter, pageParam);
return api.getActivities(includeOwn, includeReplies, filter, pageParam);
},
getNextPageParam(prevPage) {
return prevPage.next;
@ -346,9 +350,19 @@ export function useSearchForUser(handle: string, query: string) {
return {searchQuery, updateProfileSearchResult};
}
export function useSuggestedProfiles(handle: string, handles: string[]) {
export function useSuggestedProfiles(handle: string, limit = 3) {
const queryClient = useQueryClient();
const queryKey = ['profiles', {handles}];
const queryKey = ['profiles', limit];
const suggestedHandles = [
'@index@activitypub.ghost.org',
'@index@john.onolan.org',
'@index@www.coffeeandcomplexity.com',
'@index@ghost.codenamejimmy.com',
'@index@www.syphoncontinuity.com',
'@index@www.cosmico.org',
'@index@silverhuang.com'
];
const suggestedProfilesQuery = useQuery({
queryKey,
@ -357,7 +371,10 @@ export function useSuggestedProfiles(handle: string, handles: string[]) {
const api = createActivityPubAPI(handle, siteUrl);
return Promise.allSettled(
handles.map(h => api.getProfile(h))
suggestedHandles
.sort(() => Math.random() - 0.5)
.slice(0, limit)
.map(suggestedHandle => api.getProfile(suggestedHandle))
).then((results) => {
return results
.filter((result): result is PromiseFulfilledResult<Profile> => result.status === 'fulfilled')
@ -494,7 +511,7 @@ export function useNoteMutationForUser(handle: string) {
return [activity, ...current];
});
queryClient.setQueriesData([`activities:${handle}`], (current?: {pages: {data: Activity[]}[]}) => {
queryClient.setQueriesData([`activities:${handle}`, GET_ACTIVITIES_QUERY_KEY_FEED], (current?: {pages: {data: Activity[]}[]}) => {
if (current === undefined) {
return current;
}

View file

@ -1,27 +0,0 @@
import {useMemo} from 'react';
import {useSuggestedProfiles as useSuggestedProfilesQuery} from '../hooks/useActivityPubQueries';
export const SUGGESTED_HANDLES = [
'@index@activitypub.ghost.org',
'@index@john.onolan.org',
'@index@www.coffeeandcomplexity.com',
'@index@ghost.codenamejimmy.com',
'@index@www.syphoncontinuity.com',
'@index@www.cosmico.org',
'@index@silverhuang.com'
];
const useSuggestedProfiles = (limit = 3) => {
const handles = useMemo(() => {
return SUGGESTED_HANDLES
.sort(() => Math.random() - 0.5)
.slice(0, limit);
}, [limit]);
const {suggestedProfilesQuery, updateSuggestedProfile} = useSuggestedProfilesQuery('index', handles);
const {data: suggested = [], isLoading: isLoadingSuggested} = suggestedProfilesQuery;
return {suggested, isLoadingSuggested, updateSuggestedProfile};
};
export default useSuggestedProfiles;

View file

@ -1,71 +0,0 @@
import {Mock, vi} from 'vitest';
import {renderHook} from '@testing-library/react';
import type {UseQueryResult} from '@tanstack/react-query';
import * as useActivityPubQueries from '../../../src/hooks/useActivityPubQueries';
import useSuggestedProfiles, {SUGGESTED_HANDLES} from '../../../src/hooks/useSuggestedProfiles';
import type{Profile} from '../../../src/api/activitypub';
vi.mock('../../../src/hooks/useActivityPubQueries');
describe('useSuggestedProfiles', function () {
let mockUpdateSuggestedProfile: Mock;
beforeEach(function () {
mockUpdateSuggestedProfile = vi.fn();
vi.mocked(useActivityPubQueries.useSuggestedProfiles).mockImplementation((handle, handles) => {
// We expect the handle to be 'index', throw if anything else
if (handle !== 'index') {
throw new Error(`Expected handle to be: [index], got: [${handle}]`);
}
// Return the mocked query result making sure that the data is the
// same as the handles passed in. For the purposes of this test,
// we don't need to test the internals of useSuggestedProfilesQuery
return {
suggestedProfilesQuery: {
data: handles,
isLoading: false
} as unknown as UseQueryResult<Profile[], unknown>,
updateSuggestedProfile: mockUpdateSuggestedProfile
};
});
});
it('should return the default number of suggested profiles', function () {
const {result} = renderHook(() => useSuggestedProfiles());
// Check that the correct number of suggested profiles are returned
expect(result.current.suggested.length).toEqual(3);
// Check that the suggested profiles are in the SUGGESTED_HANDLES array
result.current.suggested.forEach((suggested) => {
expect(SUGGESTED_HANDLES).toContain(suggested);
});
});
it('should return the specified number of suggested profiles', function () {
const {result} = renderHook(() => useSuggestedProfiles(5));
// Assert that the correct number of suggested profiles are returned
expect(result.current.suggested.length).toEqual(5);
// Assert that the suggested profiles are in the SUGGESTED_HANDLES array
result.current.suggested.forEach((suggested) => {
expect(SUGGESTED_HANDLES).toContain(suggested);
});
});
it('should return a loading state', function () {
const {result} = renderHook(() => useSuggestedProfiles());
expect(result.current.isLoadingSuggested).toEqual(false);
});
it('should return a function to update a suggested profile', function () {
const {result} = renderHook(() => useSuggestedProfiles());
expect(result.current.updateSuggestedProfile).toEqual(mockUpdateSuggestedProfile);
});
});