mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
Updated profile tab to use dedicated account endpoints in admin-x-activitypub
(#22010)
refs [AP-647](https://linear.app/ghost/issue/AP-648/refactor-profile-tab-to-use-account-and-follows) Updated the profile tab in `admin-x-activitypub` to use dedicated account endpoints. This is to remove coupling between the UI and the ActivityPub endpoints in preparation for the upcoming changes around storing `accounts` and `follows` in the database
This commit is contained in:
parent
73f8bcf0b3
commit
6bc164cb7c
5 changed files with 196 additions and 121 deletions
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@tryghost/admin-x-activitypub",
|
||||
"version": "0.3.44",
|
||||
"version": "0.3.45",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
@ -43,6 +43,34 @@ export interface GetPostsForProfileResponse {
|
|||
next: string | null;
|
||||
}
|
||||
|
||||
export type AccountFollowsType = 'following' | 'followers';
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
handle: string;
|
||||
bio: string;
|
||||
url: string;
|
||||
avatarUrl: string;
|
||||
bannerImageUrl: string | null;
|
||||
customFields: Record<string, string>;
|
||||
postsCount: number;
|
||||
likedCount: number;
|
||||
followingCount: number;
|
||||
followerCount: number;
|
||||
followsMe: boolean;
|
||||
followedByMe: boolean;
|
||||
}
|
||||
|
||||
type GetAccountResponse = Account
|
||||
|
||||
export type MinimalAccount = Pick<Account, 'id' | 'name' | 'handle' | 'avatarUrl'>;
|
||||
|
||||
export interface GetAccountFollowsResponse {
|
||||
accounts: MinimalAccount[];
|
||||
next: string | null;
|
||||
}
|
||||
|
||||
export class ActivityPubAPI {
|
||||
constructor(
|
||||
private readonly apiUrl: URL,
|
||||
|
@ -114,20 +142,6 @@ export class ActivityPubAPI {
|
|||
};
|
||||
}
|
||||
|
||||
private async getActivityPubCollectionCount(collectionUrl: URL): Promise<number> {
|
||||
const json = await this.fetchJSON(collectionUrl);
|
||||
|
||||
if (json === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ('totalItems' in json && typeof json.totalItems === 'number') {
|
||||
return json.totalItems;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
get inboxApiUrl() {
|
||||
return new URL(`.ghost/activitypub/inbox/${this.handle}`, this.apiUrl);
|
||||
}
|
||||
|
@ -162,10 +176,6 @@ export class ActivityPubAPI {
|
|||
return this.getActivityPubCollection<Actor>(this.followingApiUrl, cursor);
|
||||
}
|
||||
|
||||
async getFollowingCount(): Promise<number> {
|
||||
return this.getActivityPubCollectionCount(this.followingApiUrl);
|
||||
}
|
||||
|
||||
get followersApiUrl() {
|
||||
return new URL(`.ghost/activitypub/followers/${this.handle}`, this.apiUrl);
|
||||
}
|
||||
|
@ -174,10 +184,6 @@ export class ActivityPubAPI {
|
|||
return this.getActivityPubCollection<Actor>(this.followersApiUrl, cursor);
|
||||
}
|
||||
|
||||
async getFollowersCount(): Promise<number> {
|
||||
return this.getActivityPubCollectionCount(this.followersApiUrl);
|
||||
}
|
||||
|
||||
async follow(username: string): Promise<Actor> {
|
||||
const url = new URL(`.ghost/activitypub/actions/follow/${username}`, this.apiUrl);
|
||||
const json = await this.fetchJSON(url, 'POST');
|
||||
|
@ -192,10 +198,6 @@ export class ActivityPubAPI {
|
|||
return this.getActivityPubCollection<Activity>(this.likedApiUrl, cursor);
|
||||
}
|
||||
|
||||
async getLikedCount(): Promise<number> {
|
||||
return this.getActivityPubCollectionCount(this.likedApiUrl);
|
||||
}
|
||||
|
||||
async like(id: string): Promise<void> {
|
||||
const url = new URL(`.ghost/activitypub/actions/like/${encodeURIComponent(id)}`, this.apiUrl);
|
||||
await this.fetchJSON(url, 'POST');
|
||||
|
@ -404,4 +406,46 @@ export class ActivityPubAPI {
|
|||
const json = await this.fetchJSON(url);
|
||||
return json as ActivityThread;
|
||||
}
|
||||
|
||||
get accountApiUrl() {
|
||||
return new URL(`.ghost/activitypub/account/${this.handle}`, this.apiUrl);
|
||||
}
|
||||
|
||||
async getAccount(): Promise<GetAccountResponse> {
|
||||
const json = await this.fetchJSON(this.accountApiUrl);
|
||||
|
||||
return json as GetAccountResponse;
|
||||
}
|
||||
|
||||
async getAccountFollows(type: AccountFollowsType, next?: string): Promise<GetAccountFollowsResponse> {
|
||||
const url = new URL(`.ghost/activitypub/account/${this.handle}/follows/${type}`, this.apiUrl);
|
||||
if (next) {
|
||||
url.searchParams.set('next', next);
|
||||
}
|
||||
|
||||
const json = await this.fetchJSON(url);
|
||||
|
||||
if (json === null) {
|
||||
return {
|
||||
accounts: [],
|
||||
next: null
|
||||
};
|
||||
}
|
||||
|
||||
if (!('accounts' in json)) {
|
||||
return {
|
||||
accounts: [],
|
||||
next: null
|
||||
};
|
||||
}
|
||||
|
||||
const accounts = Array.isArray(json.accounts) ? json.accounts : [];
|
||||
const nextPage = 'next' in json && typeof json.next === 'string' ? json.next : null;
|
||||
|
||||
return {
|
||||
accounts,
|
||||
next: nextPage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,19 +4,15 @@ import NiceModal from '@ebay/nice-modal-react';
|
|||
import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {Button, Heading, List, LoadingIndicator, NoValueLabel, Tab, TabView} from '@tryghost/admin-x-design-system';
|
||||
|
||||
import getName from '../utils/get-name';
|
||||
import getUsername from '../utils/get-username';
|
||||
import {
|
||||
type AccountFollowsQueryResult,
|
||||
type ActivityPubCollectionQueryResult,
|
||||
useFollowersCountForUser,
|
||||
useFollowersForUser,
|
||||
useFollowingCountForUser,
|
||||
useFollowingForUser,
|
||||
useLikedCountForUser,
|
||||
useAccountFollowsForUser,
|
||||
useAccountForUser,
|
||||
useLikedForUser,
|
||||
useOutboxForUser,
|
||||
useUserDataForUser
|
||||
useOutboxForUser
|
||||
} from '../hooks/useActivityPubQueries';
|
||||
import {MinimalAccount} from '../api/activitypub';
|
||||
import {handleViewContent} from '../utils/content-handlers';
|
||||
|
||||
import APAvatar from './global/APAvatar';
|
||||
|
@ -28,13 +24,13 @@ import ViewProfileModal from './modals/ViewProfileModal';
|
|||
import {type Activity} from '../components/activities/ActivityItem';
|
||||
|
||||
interface UseInfiniteScrollTabProps<TData> {
|
||||
useDataHook: (key: string) => ActivityPubCollectionQueryResult<TData>;
|
||||
useDataHook: (key: string) => ActivityPubCollectionQueryResult<TData> | AccountFollowsQueryResult;
|
||||
emptyStateLabel: string;
|
||||
emptyStateIcon: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to abstract away the common logic for infinite scroll in tabs
|
||||
* Hook to abstract away the common logic for infinite scroll in the tabs
|
||||
*/
|
||||
const useInfiniteScrollTab = <TData,>({useDataHook, emptyStateLabel, emptyStateIcon}: UseInfiniteScrollTabProps<TData>) => {
|
||||
const {
|
||||
|
@ -45,7 +41,15 @@ const useInfiniteScrollTab = <TData,>({useDataHook, emptyStateLabel, emptyStateI
|
|||
isLoading
|
||||
} = useDataHook('index');
|
||||
|
||||
const items = (data?.pages.flatMap(page => page.data) ?? []);
|
||||
const items = (data?.pages.flatMap((page) => {
|
||||
if ('data' in page) {
|
||||
return page.data;
|
||||
} else if ('accounts' in page) {
|
||||
return page.accounts as TData[];
|
||||
}
|
||||
|
||||
return [];
|
||||
}) ?? []);
|
||||
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
@ -172,15 +176,15 @@ const LikesTab: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const handleUserClick = (actor: ActorProperties) => {
|
||||
const handleAccountClick = (handle: string) => {
|
||||
NiceModal.show(ViewProfileModal, {
|
||||
profile: getUsername(actor)
|
||||
profile: handle
|
||||
});
|
||||
};
|
||||
|
||||
const FollowingTab: React.FC = () => {
|
||||
const {items: following, EmptyState, LoadingState} = useInfiniteScrollTab<ActorProperties>({
|
||||
useDataHook: useFollowingForUser,
|
||||
const {items: accounts, EmptyState, LoadingState} = useInfiniteScrollTab<MinimalAccount>({
|
||||
useDataHook: handle => useAccountFollowsForUser(handle, 'following'),
|
||||
emptyStateLabel: 'You aren\'t following anyone yet.',
|
||||
emptyStateIcon: 'user-add'
|
||||
});
|
||||
|
@ -190,21 +194,26 @@ const FollowingTab: React.FC = () => {
|
|||
<EmptyState />
|
||||
{
|
||||
<List>
|
||||
{following.map((item, index) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{accounts.map((account, index) => (
|
||||
<React.Fragment key={account.id}>
|
||||
<ActivityItem
|
||||
key={item.id}
|
||||
onClick={() => handleUserClick(item)}
|
||||
key={account.id}
|
||||
onClick={() => handleAccountClick(account.handle)}
|
||||
>
|
||||
<APAvatar author={item} />
|
||||
<APAvatar author={{
|
||||
icon: {
|
||||
url: account.avatarUrl
|
||||
},
|
||||
name: account.name
|
||||
}} />
|
||||
<div>
|
||||
<div className='text-grey-600'>
|
||||
<span className='mr-1 font-bold text-black'>{getName(item)}</span>
|
||||
<div className='text-sm'>{getUsername(item)}</div>
|
||||
<span className='mr-1 font-bold text-black'>{account.name}</span>
|
||||
<div className='text-sm'>{account.handle}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ActivityItem>
|
||||
{index < following.length - 1 && <Separator />}
|
||||
{index < accounts.length - 1 && <Separator />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
|
@ -215,8 +224,8 @@ const FollowingTab: React.FC = () => {
|
|||
};
|
||||
|
||||
const FollowersTab: React.FC = () => {
|
||||
const {items: followers, EmptyState, LoadingState} = useInfiniteScrollTab<ActorProperties>({
|
||||
useDataHook: useFollowersForUser,
|
||||
const {items: accounts, EmptyState, LoadingState} = useInfiniteScrollTab<MinimalAccount>({
|
||||
useDataHook: handle => useAccountFollowsForUser(handle, 'followers'),
|
||||
emptyStateLabel: 'Nobody\'s following you yet. Their loss!',
|
||||
emptyStateIcon: 'user-add'
|
||||
});
|
||||
|
@ -226,21 +235,26 @@ const FollowersTab: React.FC = () => {
|
|||
<EmptyState />
|
||||
{
|
||||
<List>
|
||||
{followers.map((item, index) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{accounts.map((account, index) => (
|
||||
<React.Fragment key={account.id}>
|
||||
<ActivityItem
|
||||
key={item.id}
|
||||
onClick={() => handleUserClick(item)}
|
||||
key={account.id}
|
||||
onClick={() => handleAccountClick(account.handle)}
|
||||
>
|
||||
<APAvatar author={item} />
|
||||
<APAvatar author={{
|
||||
icon: {
|
||||
url: account.avatarUrl
|
||||
},
|
||||
name: account.name
|
||||
}} />
|
||||
<div>
|
||||
<div className='text-grey-600'>
|
||||
<span className='mr-1 font-bold text-black'>{item.name || getName(item) || 'Unknown'}</span>
|
||||
<div className='text-sm'>{getUsername(item)}</div>
|
||||
<span className='mr-1 font-bold text-black'>{account.name}</span>
|
||||
<div className='text-sm'>{account.handle}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ActivityItem>
|
||||
{index < followers.length - 1 && <Separator />}
|
||||
{index < accounts.length - 1 && <Separator />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
|
@ -255,12 +269,7 @@ type ProfileTab = 'posts' | 'likes' | 'following' | 'followers';
|
|||
interface ProfileProps {}
|
||||
|
||||
const Profile: React.FC<ProfileProps> = ({}) => {
|
||||
const {data: followersCount = 0, isLoading: isLoadingFollowersCount} = useFollowersCountForUser('index');
|
||||
const {data: followingCount = 0, isLoading: isLoadingFollowingCount} = useFollowingCountForUser('index');
|
||||
const {data: likedCount = 0, isLoading: isLoadingLikedCount} = useLikedCountForUser('index');
|
||||
const {data: userProfile, isLoading: isLoadingProfile} = useUserDataForUser('index') as {data: ActorProperties | null, isLoading: boolean};
|
||||
|
||||
const isInitialLoading = isLoadingProfile || isLoadingFollowersCount || isLoadingFollowingCount || isLoadingLikedCount;
|
||||
const {data: account, isLoading: isLoadingAccount} = useAccountForUser('index');
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState<ProfileTab>('posts');
|
||||
|
||||
|
@ -282,7 +291,7 @@ const Profile: React.FC<ProfileProps> = ({}) => {
|
|||
<LikesTab />
|
||||
</div>
|
||||
),
|
||||
counter: likedCount
|
||||
counter: account?.likedCount || 0
|
||||
},
|
||||
{
|
||||
id: 'following',
|
||||
|
@ -292,7 +301,7 @@ const Profile: React.FC<ProfileProps> = ({}) => {
|
|||
<FollowingTab />
|
||||
</div>
|
||||
),
|
||||
counter: followingCount
|
||||
counter: account?.followingCount || 0
|
||||
},
|
||||
{
|
||||
id: 'followers',
|
||||
|
@ -302,11 +311,16 @@ const Profile: React.FC<ProfileProps> = ({}) => {
|
|||
<FollowersTab />
|
||||
</div>
|
||||
),
|
||||
counter: followersCount
|
||||
counter: account?.followerCount || 0
|
||||
}
|
||||
].filter(Boolean) as Tab<ProfileTab>[];
|
||||
|
||||
const attachments = (userProfile?.attachment || []);
|
||||
const customFields = Object.keys(account?.customFields || {}).map((key) => {
|
||||
return {
|
||||
name: key,
|
||||
value: account!.customFields[key]
|
||||
};
|
||||
}) || [];
|
||||
|
||||
const [isExpanded, setisExpanded] = useState(false);
|
||||
|
||||
|
@ -326,45 +340,50 @@ const Profile: React.FC<ProfileProps> = ({}) => {
|
|||
return (
|
||||
<>
|
||||
<MainNavigation page='profile' />
|
||||
{isInitialLoading ? (
|
||||
{isLoadingAccount ? (
|
||||
<div className='flex h-[calc(100vh-8rem)] items-center justify-center'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<div className='z-0 mx-auto mt-8 flex w-full max-w-[580px] flex-col items-center pb-16'>
|
||||
<div className='mx-auto w-full'>
|
||||
{userProfile?.image && (
|
||||
{account?.bannerImageUrl && (
|
||||
<div className='h-[200px] w-full overflow-hidden rounded-lg bg-gradient-to-tr from-grey-200 to-grey-100'>
|
||||
<img
|
||||
alt={userProfile?.name}
|
||||
alt={account?.name}
|
||||
className='h-full w-full object-cover'
|
||||
src={userProfile?.image.url}
|
||||
src={account?.bannerImageUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`${userProfile?.image && '-mt-12'} px-4`}>
|
||||
<div className={`${account?.bannerImageUrl && '-mt-12'} px-4`}>
|
||||
<div className='flex items-end justify-between'>
|
||||
<div className='rounded-xl outline outline-4 outline-white'>
|
||||
<APAvatar
|
||||
author={userProfile as ActorProperties}
|
||||
author={account && {
|
||||
icon: {
|
||||
url: account?.avatarUrl
|
||||
},
|
||||
name: account?.name
|
||||
}}
|
||||
size='lg'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Heading className='mt-4' level={3}>{userProfile?.name}</Heading>
|
||||
<Heading className='mt-4' level={3}>{account?.name}</Heading>
|
||||
<span className='mt-1 text-[1.5rem] text-grey-800'>
|
||||
<span>{userProfile && getUsername(userProfile)}</span>
|
||||
<span>{account?.handle}</span>
|
||||
</span>
|
||||
{(userProfile?.summary || attachments.length > 0) && (
|
||||
{(account?.bio || customFields.length > 0) && (
|
||||
<div ref={contentRef} className={`ap-profile-content transition-max-height relative text-[1.5rem] duration-300 ease-in-out [&>p]:mb-3 ${isExpanded ? 'max-h-none pb-7' : 'max-h-[160px] overflow-hidden'} relative`}>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{__html: userProfile?.summary ?? ''}}
|
||||
dangerouslySetInnerHTML={{__html: account?.bio ?? ''}}
|
||||
className='ap-profile-content mt-3 text-[1.5rem] [&>p]:mb-3'
|
||||
/>
|
||||
{attachments.map(attachment => (
|
||||
<span className='mt-3 line-clamp-1 flex flex-col text-[1.5rem]'>
|
||||
<span className={`text-xs font-semibold`}>{attachment.name}</span>
|
||||
<span dangerouslySetInnerHTML={{__html: attachment.value}} className='ap-profile-content truncate'/>
|
||||
{customFields.map(customField => (
|
||||
<span key={customField.name} className='mt-3 line-clamp-1 flex flex-col text-[1.5rem]'>
|
||||
<span className={`text-xs font-semibold`}>{customField.name}</span>
|
||||
<span dangerouslySetInnerHTML={{__html: customField.value}} className='ap-profile-content truncate'/>
|
||||
</span>
|
||||
))}
|
||||
{!isExpanded && isOverflowing && (
|
||||
|
|
|
@ -9,7 +9,12 @@ import {Icon} from '@tryghost/admin-x-design-system';
|
|||
type AvatarSize = '2xs' | 'xs' | 'sm' | 'lg' | 'notification';
|
||||
|
||||
interface APAvatarProps {
|
||||
author: ActorProperties | undefined;
|
||||
author: {
|
||||
icon: {
|
||||
url: string;
|
||||
};
|
||||
name: string;
|
||||
} | undefined;
|
||||
size?: AvatarSize;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,10 +1,25 @@
|
|||
import {
|
||||
type AccountFollowsType,
|
||||
ActivityPubAPI,
|
||||
ActivityPubCollectionResponse,
|
||||
ActivityThread,
|
||||
type GetAccountFollowsResponse,
|
||||
type Profile,
|
||||
type SearchResults
|
||||
} from '../api/activitypub';
|
||||
import {Activity} from '../components/activities/ActivityItem';
|
||||
import {ActivityPubAPI, ActivityPubCollectionResponse, ActivityThread, type Profile, type SearchResults} from '../api/activitypub';
|
||||
import {type UseInfiniteQueryResult, useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {
|
||||
type UseInfiniteQueryResult,
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
let SITE_URL: string;
|
||||
|
||||
export type ActivityPubCollectionQueryResult<TData> = UseInfiniteQueryResult<ActivityPubCollectionResponse<TData>>;
|
||||
export type AccountFollowsQueryResult = UseInfiniteQueryResult<GetAccountFollowsResponse>;
|
||||
|
||||
async function getSiteUrl() {
|
||||
if (!SITE_URL) {
|
||||
|
@ -52,17 +67,6 @@ export function useLikedForUser(handle: string) {
|
|||
});
|
||||
}
|
||||
|
||||
export function useLikedCountForUser(handle: string) {
|
||||
return useQuery({
|
||||
queryKey: [`likedCount:${handle}`],
|
||||
async queryFn() {
|
||||
const siteUrl = await getSiteUrl();
|
||||
const api = createActivityPubAPI(handle, siteUrl);
|
||||
return api.getLikedCount();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useLikeMutationForUser(handle: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
@ -183,17 +187,6 @@ export function useFollowersForUser(handle: string) {
|
|||
});
|
||||
}
|
||||
|
||||
export function useFollowersCountForUser(handle: string) {
|
||||
return useQuery({
|
||||
queryKey: [`followersCount:${handle}`],
|
||||
async queryFn() {
|
||||
const siteUrl = await getSiteUrl();
|
||||
const api = createActivityPubAPI(handle, siteUrl);
|
||||
return api.getFollowersCount();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useFollowingForUser(handle: string) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: [`following:${handle}`],
|
||||
|
@ -208,17 +201,6 @@ export function useFollowingForUser(handle: string) {
|
|||
});
|
||||
}
|
||||
|
||||
export function useFollowingCountForUser(handle: string) {
|
||||
return useQuery({
|
||||
queryKey: [`followingCount:${handle}`],
|
||||
async queryFn() {
|
||||
const siteUrl = await getSiteUrl();
|
||||
const api = createActivityPubAPI(handle, siteUrl);
|
||||
return api.getFollowingCount();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useFollow(handle: string, onSuccess: () => void, onError: () => void) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
@ -547,3 +529,28 @@ export function useNoteMutationForUser(handle: string) {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useAccountForUser(handle: string) {
|
||||
return useQuery({
|
||||
queryKey: [`account:${handle}`],
|
||||
async queryFn() {
|
||||
const siteUrl = await getSiteUrl();
|
||||
const api = createActivityPubAPI(handle, siteUrl);
|
||||
return api.getAccount();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useAccountFollowsForUser(handle: string, type: AccountFollowsType) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: [`follows:${handle}:${type}`],
|
||||
async queryFn({pageParam}: {pageParam?: string}) {
|
||||
const siteUrl = await getSiteUrl();
|
||||
const api = createActivityPubAPI(handle, siteUrl);
|
||||
return api.getAccountFollows(type, pageParam);
|
||||
},
|
||||
getNextPageParam(prevPage) {
|
||||
return prevPage.next;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue