Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/common/src/api/tan-query/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './useFollowEvent'
export * from './useRemixContest'
export * from './useRemixContestWinners'
export * from './useUserHasRemixContest'
export * from './useUserRemixContests'

// Mutations
export * from './useCreateEvent'
Expand Down
83 changes: 16 additions & 67 deletions packages/common/src/api/tan-query/events/useUserHasRemixContest.ts
Original file line number Diff line number Diff line change
@@ -1,78 +1,27 @@
import { useEffect, useMemo } from 'react'

import { EventEntityTypeEnum, EventEventTypeEnum } from '@audius/sdk'
import { useQueryClient } from '@tanstack/react-query'

import { ID } from '~/models'
import { Event } from '~/models/Event'

import { useAllRemixContests } from './useAllRemixContests'
import { getEventIdsByEntityIdQueryKey, getEventQueryKey } from './utils'
import { useUserRemixContests } from './useUserRemixContests'

const PAGE_SIZE = 50
const MAX_PAGES_TO_LOAD = 5
const PAGE_SIZE = 1

/**
* Returns whether the given user hosts any remix contest. The discovery
* endpoint behind `useAllRemixContests` doesn't yet support filtering by
* host userId, so the global list is paginated client-side and matched
* against `event.userId`. Mirrors the pagination cap used by the profile
* Contests tab so a profile can decide whether to show the tab at all.
*
* The events returned by the SDK are primed into the React Query cache by
* `useAllRemixContests`, so the per-track lookup here is a synchronous cache
* read in practice.
* Returns whether the given user hosts any remix contest. Calls the
* per-user endpoint (`useUserRemixContests`) with `pageSize: 1` so a
* positive answer is one row, and a negative answer is one query — no
* client-side scanning of the global list.
*/
export const useUserHasRemixContest = (hostUserId: ID | null | undefined) => {
const queryClient = useQueryClient()

const enabled = hostUserId != null
const {
data: trackIds,
isPending,
isFetching,
hasNextPage,
isFetchingNextPage,
fetchNextPage
} = useAllRemixContests({ pageSize: PAGE_SIZE }, { enabled })

const loadedPages = trackIds ? Math.ceil(trackIds.length / PAGE_SIZE) : 0

useEffect(() => {
if (
hostUserId != null &&
hasNextPage &&
!isFetchingNextPage &&
loadedPages < MAX_PAGES_TO_LOAD
) {
fetchNextPage()
}
}, [hostUserId, hasNextPage, isFetchingNextPage, loadedPages, fetchNextPage])

const hasContest = useMemo(() => {
if (!hostUserId || !trackIds) return false
return trackIds.some((trackId) => {
const eventIds = queryClient.getQueryData<ID[]>(
getEventIdsByEntityIdQueryKey({
entityId: trackId,
entityType: EventEntityTypeEnum.Track,
eventType: EventEventTypeEnum.RemixContest
})
)
const eventId = eventIds?.[0]
if (!eventId) return false
const event = queryClient.getQueryData<Event>(getEventQueryKey(eventId))
return event?.userId === hostUserId
})
}, [hostUserId, trackIds, queryClient])

// Match-not-yet-found is ambiguous while pages are still being fetched —
// surface that so callers can hold off on hiding the tab and avoid a
// late "tab appears" flash for hosts whose contests sit on later pages.
const isResolving =
isPending ||
(!hasContest &&
(isFetching || (hasNextPage && loadedPages < MAX_PAGES_TO_LOAD)))
const { data: trackIds, isPending, isFetching } = useUserRemixContests(
{ userId: hostUserId, pageSize: PAGE_SIZE },
{ enabled }
)

const hasContest = (trackIds?.length ?? 0) > 0
// While the first page is still loading the answer is ambiguous; surface
// that so callers can hold off on hiding the tab and avoid a late
// "tab appears" flash.
const isResolving = isPending || (!hasContest && isFetching)

return { hasContest, isPending: isResolving }
}
143 changes: 143 additions & 0 deletions packages/common/src/api/tan-query/events/useUserRemixContests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {
EventEntityTypeEnum,
EventEventTypeEnum,
GetContestsByUserStatusEnum,
Id,
OptionalHashId,
Event as SDKEvent
} from '@audius/sdk'
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'

import { eventMetadataFromSDK } from '~/adapters/event'
import { getRemixesQueryKey } from '~/api/tan-query/remixes/useRemixes'
import { useQueryContext } from '~/api/tan-query/utils'
import { primeRelatedData } from '~/api/tan-query/utils/primeRelatedData'
import { ID } from '~/models'
import { removeNullable } from '~/utils'

import { QUERY_KEYS } from '../queryKeys'
import { QueryKey, QueryOptions } from '../types'

import { getEventIdsByEntityIdQueryKey, getEventQueryKey } from './utils'

const DEFAULT_PAGE_SIZE = 25

export type UserRemixContestStatus = GetContestsByUserStatusEnum

type UseUserRemixContestsArgs = {
userId: ID | null | undefined
pageSize?: number
/**
* Filter by contest status. Defaults to `'all'` (the backend's default),
* which returns active contests first (ordered by soonest-ending end_date)
* followed by ended contests (most-recently-ended first).
*/
status?: UserRemixContestStatus
}

export const getUserRemixContestsQueryKey = ({
userId,
pageSize = DEFAULT_PAGE_SIZE,
status = GetContestsByUserStatusEnum.All
}: UseUserRemixContestsArgs) =>
[
QUERY_KEYS.userRemixContests,
{ userId, pageSize, status }
] as unknown as QueryKey<ID[]>

/**
* Hook to fetch remix contest events hosted by a specific user with infinite
* query support. Calls the dedicated endpoint
* `GET /v1/users/{id}/contests` (SDK: `users.getContestsByUser`), which returns
* events ordered with currently-active contests first (by soonest-ending
* end_date) followed by ended contests.
*
* Each page is mapped to the remix contest's parent track ID
* (`event.entityId`) so consumers like `ContestCard` can receive a
* `trackId` prop and resolve the event internally via `useRemixContest`.
*/
export const useUserRemixContests = (
{
userId,
pageSize = DEFAULT_PAGE_SIZE,
status = GetContestsByUserStatusEnum.All
}: UseUserRemixContestsArgs,
options?: QueryOptions
) => {
const { audiusSdk } = useQueryContext()
const queryClient = useQueryClient()

return useInfiniteQuery({
queryKey: getUserRemixContestsQueryKey({ userId, pageSize, status }),
initialPageParam: 0,
getNextPageParam: (lastPage: ID[], allPages) => {
if (lastPage.length < pageSize) return undefined
return allPages.length * pageSize
},
queryFn: async ({ pageParam }) => {
const sdk = await audiusSdk()
const { data, related } = await sdk.users.getContestsByUser({
id: Id.parse(userId),
limit: pageSize,
offset: pageParam as number,
status
})

// Prime related tracks + users (full objects, delivered alongside the
// event list on the per-user endpoint, same shape as the discovery
// endpoint).
primeRelatedData({ related, queryClient })

// Prime useRemixes({ trackId, pageSize: 0, isContestEntry: true }) so
// ContestCard's entry-count badge doesn't fire a count-only request
// per card.
const entryCounts = related?.entryCounts ?? {}
for (const [hashedTrackId, count] of Object.entries(entryCounts)) {
const trackId = OptionalHashId.parse(hashedTrackId)
if (!trackId) continue
queryClient.setQueryData(
getRemixesQueryKey({
trackId,
pageSize: 0,
isContestEntry: true
}),
{
pages: [{ count, tracks: [] }],
pageParams: [0]
} as unknown as never
)
}

if (!data) return []

return data
.map((sdkEvent: SDKEvent) => {
const event = eventMetadataFromSDK(sdkEvent)
if (!event) return null
// Prime the per-event cache so useEvent hits immediately downstream.
queryClient.setQueryData(getEventQueryKey(event.eventId), event)
// useRemixContest resolves via useEventIdsByEntityId keyed by
// (entityId, entityType=Track, eventType=RemixContest). Prime that
// lookup too so the card doesn't have to re-fetch the event list.
if (
event.entityId &&
event.entityType === EventEntityTypeEnum.Track
) {
queryClient.setQueryData(
getEventIdsByEntityIdQueryKey({
entityId: event.entityId,
entityType: EventEntityTypeEnum.Track,
eventType: EventEventTypeEnum.RemixContest
}),
[event.eventId]
)
}
return event.entityId ?? null
})
.filter(removeNullable)
},
enabled: !!userId && options?.enabled !== false,
select: (data) => data.pages.flat(),
...options
})
}
1 change: 1 addition & 0 deletions packages/common/src/api/tan-query/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export const QUERY_KEYS = {
events: 'events',
eventsByEntityId: 'eventsByEntityId',
remixContestsList: 'remixContestsList',
userRemixContests: 'userRemixContests',
walletOwner: 'walletOwner',
tokenPrice: 'tokenPrice',
usdcBalance: 'usdcBalance',
Expand Down
71 changes: 71 additions & 0 deletions packages/sdk/src/sdk/api/generated/default/apis/UsersApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type {
PurchasesCountResponse,
PurchasesResponse,
RelatedArtistResponse,
RemixContestsResponse,
RemixersCountResponse,
RemixersResponse,
Reposts,
Expand Down Expand Up @@ -139,6 +140,8 @@ import {
PurchasesResponseToJSON,
RelatedArtistResponseFromJSON,
RelatedArtistResponseToJSON,
RemixContestsResponseFromJSON,
RemixContestsResponseToJSON,
RemixersCountResponseFromJSON,
RemixersCountResponseToJSON,
RemixersResponseFromJSON,
Expand Down Expand Up @@ -314,6 +317,13 @@ export interface GetConnectedWalletsRequest {
id: string;
}

export interface GetContestsByUserRequest {
id: string;
offset?: number;
limit?: number;
status?: GetContestsByUserStatusEnum;
}

export interface GetFollowersRequest {
id: string;
offset?: number;
Expand Down Expand Up @@ -1705,6 +1715,58 @@ export class UsersApi extends runtime.BaseAPI {
return await response.value();
}

/**
* @hidden
* Get the remix contests hosted by a single user, ordered with currently-active contests first (by soonest-ending end_date) followed by ended contests (most-recently-ended first). Mirrors the response shape of `GET /events/remix-contests` (data + related users / tracks / entry_counts).
* Get contests hosted by user
*/
async getContestsByUserRaw(params: GetContestsByUserRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<RemixContestsResponse>> {
if (params.id === null || params.id === undefined) {
throw new runtime.RequiredError('id','Required parameter params.id was null or undefined when calling getContestsByUser.');
}

const queryParameters: any = {};

if (params.offset !== undefined) {
queryParameters['offset'] = params.offset;
}

if (params.limit !== undefined) {
queryParameters['limit'] = params.limit;
}

if (params.status !== undefined) {
queryParameters['status'] = params.status;
}

const headerParameters: runtime.HTTPHeaders = {};

if (!headerParameters["Authorization"] && this.configuration && this.configuration.accessToken) {
const token = await this.configuration.accessToken("OAuth2", ["read"]);
if (token) {
headerParameters["Authorization"] = token;
}
}

const response = await this.request({
path: `/users/{id}/contests`.replace(`{${"id"}}`, encodeURIComponent(String(params.id))),
method: 'GET',
headers: headerParameters,
query: queryParameters,
}, initOverrides);

return new runtime.JSONApiResponse(response, (jsonValue) => RemixContestsResponseFromJSON(jsonValue));
}

/**
* Get the remix contests hosted by a single user, ordered with currently-active contests first (by soonest-ending end_date) followed by ended contests (most-recently-ended first). Mirrors the response shape of `GET /events/remix-contests` (data + related users / tracks / entry_counts).
* Get contests hosted by user
*/
async getContestsByUser(params: GetContestsByUserRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<RemixContestsResponse> {
const response = await this.getContestsByUserRaw(params, initOverrides);
return await response.value();
}

/**
* @hidden
* All users that follow the provided user
Expand Down Expand Up @@ -5233,6 +5295,15 @@ export const GetAudioTransactionsSortDirectionEnum = {
Desc: 'desc'
} as const;
export type GetAudioTransactionsSortDirectionEnum = typeof GetAudioTransactionsSortDirectionEnum[keyof typeof GetAudioTransactionsSortDirectionEnum];
/**
* @export
*/
export const GetContestsByUserStatusEnum = {
Active: 'active',
Ended: 'ended',
All: 'all'
} as const;
export type GetContestsByUserStatusEnum = typeof GetContestsByUserStatusEnum[keyof typeof GetContestsByUserStatusEnum];
/**
* @export
*/
Expand Down
Loading
Loading