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
@@ -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
41 changes: 22 additions & 19 deletions packages/common/src/api/tan-query/events/useUserRemixContests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ 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).
* 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
}
Expand All @@ -41,20 +41,21 @@ export const getUserRemixContestsQueryKey = ({
status = GetContestsByUserStatusEnum.All
}: UseUserRemixContestsArgs) =>
[
QUERY_KEYS.userRemixContests,
{ userId, pageSize, status }
QUERY_KEYS.userRemixContestsList,
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.
* 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 remix contest's parent track ID
* (`event.entityId`) so consumers like `ContestCard` can receive a
* `trackId` prop and resolve the event internally via `useRemixContest`.
* 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 = (
{
Expand All @@ -75,22 +76,24 @@ export const useUserRemixContests = (
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 as number,
offset: pageParam,
status
})

// Prime related tracks + users (full objects, delivered alongside the
// event list on the per-user endpoint, same shape as the discovery
// endpoint).
// 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.
// per card. Mirrors the priming in useAllRemixContests.
const entryCounts = related?.entryCounts ?? {}
for (const [hashedTrackId, count] of Object.entries(entryCounts)) {
const trackId = OptionalHashId.parse(hashedTrackId)
Expand All @@ -114,7 +117,7 @@ export const useUserRemixContests = (
.map((sdkEvent: SDKEvent) => {
const event = eventMetadataFromSDK(sdkEvent)
if (!event) return null
// Prime the per-event cache so useEvent hits immediately downstream.
// 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
Expand All @@ -136,8 +139,8 @@ export const useUserRemixContests = (
})
.filter(removeNullable)
},
enabled: !!userId && options?.enabled !== false,
select: (data) => data.pages.flat(),
enabled: options?.enabled !== false && !!userId,
...options
})
}
2 changes: 1 addition & 1 deletion packages/common/src/api/tan-query/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export const QUERY_KEYS = {
events: 'events',
eventsByEntityId: 'eventsByEntityId',
remixContestsList: 'remixContestsList',
userRemixContests: 'userRemixContests',
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,85 +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()

// Larger page size + auto-pagination below: the discovery endpoint
// doesn't support `host=…`, so we paginate the global list and
// filter client-side. Bumped from 50 → 100 because hosts whose
// contests sit deep in the global list (ended contests, smaller
// accounts) were missing from the tab — Julian's @dimensionx
// report. Together with the `useEffect` below that drains pages
// until exhausted, this guarantees the host's contests appear once
// they're anywhere in the result set.
const {
data: trackIds,
isPending,
isFetching,
hasNextPage,
fetchNextPage,
isFetchingNextPage
} = useAllRemixContests({ pageSize: 100 }, { 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 @@ -116,7 +50,7 @@ export const ContestsTab = () => {
)
}

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

Expand Down
Loading