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:
parent
995edaa966
commit
04ed106c73
10 changed files with 65 additions and 144 deletions
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@tryghost/admin-x-activitypub",
|
||||
"version": "0.3.30",
|
||||
"version": "0.3.31",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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')} />
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue