Skip to content
Open
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
@@ -1,6 +1,7 @@
// Queries
export * from './useAllEvents'
export * from './useAllRemixContests'
export * from './useUserRemixContests'
export * from './useEvent'
export * from './useEventFollowers'
export * from './useEvents'
Expand Down
146 changes: 146 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,146 @@
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'`, 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.userRemixContestsList,
userId,
{ pageSize, status }
] as unknown as QueryKey<ID[]>

/**
* Hook to fetch the remix contests hosted by a specific user with infinite
* query support. Calls `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 contest's parent track ID (`event.entityId`)
* so consumers like `ContestCard` can take 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 }) => {
if (!userId) return []
const sdk = await audiusSdk()
const { data, related } = await sdk.users.getContestsByUser({
id: Id.parse(userId),
limit: pageSize,
offset: pageParam,
status
})

// Prime related tracks + users (full objects, delivered alongside the
// event list). This turns ContestCard's useTrack / useUser into cache
// hits so the grid can paint with one network round-trip instead of
// N+1.
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. Mirrors the priming in useAllRemixContests.
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 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)
},
select: (data) => data.pages.flat(),
enabled: options?.enabled !== false && !!userId,
...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',
userRemixContestsList: 'userRemixContestsList',
walletOwner: 'walletOwner',
tokenPrice: 'tokenPrice',
usdcBalance: 'usdcBalance',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
import { useEffect, useMemo } from 'react'

import {
getEventIdsByEntityIdQueryKey,
getEventQueryKey,
useAllRemixContests,
useProfileUser
} from '@audius/common/api'
import type { Event, ID } from '@audius/common/models'
import { EventEntityTypeEnum, EventEventTypeEnum } from '@audius/sdk'
import { useProfileUser, useUserRemixContests } from '@audius/common/api'
import { useIsFocused } from '@react-navigation/native'
import { useQueryClient } from '@tanstack/react-query'
import { View } from 'react-native'

import { Box, Flex, LoadingSpinner } from '@audius/harmony-native'
Expand All @@ -28,77 +18,29 @@ import { EmptyProfileTile } from '../EmptyProfileTile'
* integrates with the parent `CollapsibleTabNavigator`'s scroll-tracking
* — a regular `ScrollView` here breaks the collapsible header behaviour.
*
* Filtering by host:
* The discovery `getRemixContests` endpoint doesn't yet support filtering
* by host userId, so we paginate the global list and filter
* client-side. We do the filter at the parent level by reading the
* already-primed event from React Query's cache (useAllRemixContests
* primes each event via `queryClient.setQueryData`). This avoids
* per-row `useRemixContest` calls (which can race when multiple cards
* mount at once) and gives a single deterministic list of trackIds to
* render.
*
* Auto-paginates: we proactively fetch all pages until exhausted so the
* tab is reliable for hosts whose contests sit beyond the first page of
* the global list — `onEndReached` alone wouldn't fire for users with
* just one or two visible cards (no scroll needed).
* Backed by `GET /v1/users/{id}/contests` (active first by soonest end,
* then ended) so the tab no longer needs to walk the global list and
* filter client-side.
*/
export const ContestsTab = () => {
const { user_id: hostUserId } =
useProfileUser({
select: (user) => ({ user_id: user.user_id })
}).user ?? {}
const isFocused = useIsFocused()
const queryClient = useQueryClient()

const {
data: trackIds,
isPending,
isFetching,
hasNextPage,
fetchNextPage,
isFetchingNextPage
} = useAllRemixContests({ pageSize: 50 }, { enabled: isFocused })

const allTrackIds = useMemo(() => trackIds ?? [], [trackIds])

// Filter to contests hosted by THIS profile by reading each contest's
// event from the cache (primed by useAllRemixContests). Re-derives on
// every render so newly-arrived pages flow in immediately.
const contestTrackIds = useMemo(() => {
if (hostUserId === undefined) return []
return allTrackIds.filter((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
})
}, [allTrackIds, hostUserId, queryClient])
const { data: trackIds, isPending } = useUserRemixContests(
{ userId: hostUserId, pageSize: 50 },
{ enabled: isFocused && !!hostUserId }
)

// Auto-fetch subsequent pages until exhausted — see hook docstring.
// The host's contests can sit anywhere in the global list, so a single
// page isn't enough to guarantee we've seen them all.
useEffect(() => {
if (hasNextPage && !isFetchingNextPage && isFocused) {
fetchNextPage()
}
}, [hasNextPage, isFetchingNextPage, isFocused, fetchNextPage])
const contestTrackIds = trackIds ?? []

if (!hostUserId) {
return null
}

if (
(isPending || isFetchingNextPage || hasNextPage) &&
contestTrackIds.length === 0
) {
if (isPending) {
return (
<Flex justifyContent='center' style={{ marginTop: spacing(6) }}>
<Box style={{ width: 24 }}>
Expand All @@ -108,7 +50,7 @@ export const ContestsTab = () => {
)
}

if (!isFetching && contestTrackIds.length === 0) {
if (contestTrackIds.length === 0) {
return <EmptyProfileTile tab='contests' />
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,15 @@ const SearchCategory = (props: SearchCategoryProps) => {
}

const filtersByCategory: Record<SearchCategoryType, SearchFilter[]> = {
all: [],
all: [
'genre',
'mood',
'key',
'bpm',
'isPremium',
'hasDownloads',
'isVerified'
],
users: ['genre', 'isVerified'],
tracks: [
'genre',
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
* Gets the remix contests hosted by a user, ordered with currently-active contests first (by soonest-ending end_date), followed by ended contests (most-recently-ended first). Active contests are those whose end_date is null or in the future.
* 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));
}

/**
* Gets the remix contests hosted by a user, ordered with currently-active contests first (by soonest-ending end_date), followed by ended contests (most-recently-ended first). Active contests are those whose end_date is null or in the future.
* 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