Skip to content

Commit 6a312f0

Browse files
dylanjeffersclaude
andauthored
profile: scope Contests tab to its own host endpoint (#14262)
## Summary - Replaces the profile Contests tab's global-list-then-filter approach with a single call to the new server-scoped endpoint `GET /v1/users/{id}/contests` (SDK: `sdk.users.getContestsByUser`). - Adds `useUserRemixContests` in `@audius/common` with the same prime-on-fetch flow (`primeRelatedData`, event cache, useRemixes entry-counts) as `useAllRemixContests`, so ContestCard stays a synchronous cache hit. - Removes the `MAX_PAGES_TO_LOAD` auto-pagination workaround and per-row `HostedContestCard` host-guard that existed because the old endpoint couldn't filter by host. Updates desktop, mobile-web, and React Native consumers. - SDK regen pulls in the new endpoint definition. Pairs with [audius/api#790](AudiusProject/api#790) which adds the underlying endpoint + swagger entry. ## Test plan - [ ] Visit a profile that hosts contests, switch to the Contests tab — list renders without first paging through unrelated contests - [ ] Visit a profile that has only ended contests — they appear (the old workaround pulled up to 5 pages of the global list to surface these) - [ ] Visit a profile that has hosted no contests — empty state renders - [ ] Active-then-ended ordering preserved - [ ] Confirm same behavior on mobile-web and React Native profile screens 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 61f3c90 commit 6a312f0

4 files changed

Lines changed: 35 additions & 97 deletions

File tree

packages/common/src/api/tan-query/events/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Queries
22
export * from './useAllEvents'
33
export * from './useAllRemixContests'
4+
export * from './useUserRemixContests'
45
export * from './useEvent'
56
export * from './useEventFollowers'
67
export * from './useEvents'

packages/common/src/api/tan-query/events/useUserRemixContests.ts

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ type UseUserRemixContestsArgs = {
2828
userId: ID | null | undefined
2929
pageSize?: number
3030
/**
31-
* Filter by contest status. Defaults to `'all'` (the backend's default),
32-
* which returns active contests first (ordered by soonest-ending end_date)
33-
* followed by ended contests (most-recently-ended first).
31+
* Filter by contest status. Defaults to `'all'`, which returns active
32+
* contests first (ordered by soonest-ending end_date) followed by ended
33+
* contests (most-recently-ended first).
3434
*/
3535
status?: UserRemixContestStatus
3636
}
@@ -41,20 +41,21 @@ export const getUserRemixContestsQueryKey = ({
4141
status = GetContestsByUserStatusEnum.All
4242
}: UseUserRemixContestsArgs) =>
4343
[
44-
QUERY_KEYS.userRemixContests,
45-
{ userId, pageSize, status }
44+
QUERY_KEYS.userRemixContestsList,
45+
userId,
46+
{ pageSize, status }
4647
] as unknown as QueryKey<ID[]>
4748

4849
/**
49-
* Hook to fetch remix contest events hosted by a specific user with infinite
50-
* query support. Calls the dedicated endpoint
51-
* `GET /v1/users/{id}/contests` (SDK: `users.getContestsByUser`), which returns
52-
* events ordered with currently-active contests first (by soonest-ending
53-
* end_date) followed by ended contests.
50+
* Hook to fetch the remix contests hosted by a specific user with infinite
51+
* query support. Calls `GET /v1/users/{id}/contests` (SDK:
52+
* `users.getContestsByUser`), which returns events ordered with
53+
* currently-active contests first (by soonest-ending end_date) followed by
54+
* ended contests.
5455
*
55-
* Each page is mapped to the remix contest's parent track ID
56-
* (`event.entityId`) so consumers like `ContestCard` can receive a
57-
* `trackId` prop and resolve the event internally via `useRemixContest`.
56+
* Each page is mapped to the contest's parent track ID (`event.entityId`)
57+
* so consumers like `ContestCard` can take a `trackId` prop and resolve the
58+
* event internally via `useRemixContest`.
5859
*/
5960
export const useUserRemixContests = (
6061
{
@@ -75,22 +76,24 @@ export const useUserRemixContests = (
7576
return allPages.length * pageSize
7677
},
7778
queryFn: async ({ pageParam }) => {
79+
if (!userId) return []
7880
const sdk = await audiusSdk()
7981
const { data, related } = await sdk.users.getContestsByUser({
8082
id: Id.parse(userId),
8183
limit: pageSize,
82-
offset: pageParam as number,
84+
offset: pageParam,
8385
status
8486
})
8587

8688
// Prime related tracks + users (full objects, delivered alongside the
87-
// event list on the per-user endpoint, same shape as the discovery
88-
// endpoint).
89+
// event list). This turns ContestCard's useTrack / useUser into cache
90+
// hits so the grid can paint with one network round-trip instead of
91+
// N+1.
8992
primeRelatedData({ related, queryClient })
9093

9194
// Prime useRemixes({ trackId, pageSize: 0, isContestEntry: true }) so
9295
// ContestCard's entry-count badge doesn't fire a count-only request
93-
// per card.
96+
// per card. Mirrors the priming in useAllRemixContests.
9497
const entryCounts = related?.entryCounts ?? {}
9598
for (const [hashedTrackId, count] of Object.entries(entryCounts)) {
9699
const trackId = OptionalHashId.parse(hashedTrackId)
@@ -114,7 +117,7 @@ export const useUserRemixContests = (
114117
.map((sdkEvent: SDKEvent) => {
115118
const event = eventMetadataFromSDK(sdkEvent)
116119
if (!event) return null
117-
// Prime the per-event cache so useEvent hits immediately downstream.
120+
// Prime per-event cache so useEvent hits immediately downstream.
118121
queryClient.setQueryData(getEventQueryKey(event.eventId), event)
119122
// useRemixContest resolves via useEventIdsByEntityId keyed by
120123
// (entityId, entityType=Track, eventType=RemixContest). Prime that
@@ -136,8 +139,8 @@ export const useUserRemixContests = (
136139
})
137140
.filter(removeNullable)
138141
},
139-
enabled: !!userId && options?.enabled !== false,
140142
select: (data) => data.pages.flat(),
143+
enabled: options?.enabled !== false && !!userId,
141144
...options
142145
})
143146
}

packages/common/src/api/tan-query/queryKeys.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export const QUERY_KEYS = {
9999
events: 'events',
100100
eventsByEntityId: 'eventsByEntityId',
101101
remixContestsList: 'remixContestsList',
102-
userRemixContests: 'userRemixContests',
102+
userRemixContestsList: 'userRemixContestsList',
103103
walletOwner: 'walletOwner',
104104
tokenPrice: 'tokenPrice',
105105
usdcBalance: 'usdcBalance',

packages/mobile/src/screens/profile-screen/ProfileTabs/ContestsTab.tsx

Lines changed: 11 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,5 @@
1-
import { useEffect, useMemo } from 'react'
2-
3-
import {
4-
getEventIdsByEntityIdQueryKey,
5-
getEventQueryKey,
6-
useAllRemixContests,
7-
useProfileUser
8-
} from '@audius/common/api'
9-
import type { Event, ID } from '@audius/common/models'
10-
import { EventEntityTypeEnum, EventEventTypeEnum } from '@audius/sdk'
1+
import { useProfileUser, useUserRemixContests } from '@audius/common/api'
112
import { useIsFocused } from '@react-navigation/native'
12-
import { useQueryClient } from '@tanstack/react-query'
133
import { View } from 'react-native'
144

155
import { Box, Flex, LoadingSpinner } from '@audius/harmony-native'
@@ -28,85 +18,29 @@ import { EmptyProfileTile } from '../EmptyProfileTile'
2818
* integrates with the parent `CollapsibleTabNavigator`'s scroll-tracking
2919
* — a regular `ScrollView` here breaks the collapsible header behaviour.
3020
*
31-
* Filtering by host:
32-
* The discovery `getRemixContests` endpoint doesn't yet support filtering
33-
* by host userId, so we paginate the global list and filter
34-
* client-side. We do the filter at the parent level by reading the
35-
* already-primed event from React Query's cache (useAllRemixContests
36-
* primes each event via `queryClient.setQueryData`). This avoids
37-
* per-row `useRemixContest` calls (which can race when multiple cards
38-
* mount at once) and gives a single deterministic list of trackIds to
39-
* render.
40-
*
41-
* Auto-paginates: we proactively fetch all pages until exhausted so the
42-
* tab is reliable for hosts whose contests sit beyond the first page of
43-
* the global list — `onEndReached` alone wouldn't fire for users with
44-
* just one or two visible cards (no scroll needed).
21+
* Backed by `GET /v1/users/{id}/contests` (active first by soonest end,
22+
* then ended) so the tab no longer needs to walk the global list and
23+
* filter client-side.
4524
*/
4625
export const ContestsTab = () => {
4726
const { user_id: hostUserId } =
4827
useProfileUser({
4928
select: (user) => ({ user_id: user.user_id })
5029
}).user ?? {}
5130
const isFocused = useIsFocused()
52-
const queryClient = useQueryClient()
53-
54-
// Larger page size + auto-pagination below: the discovery endpoint
55-
// doesn't support `host=…`, so we paginate the global list and
56-
// filter client-side. Bumped from 50 → 100 because hosts whose
57-
// contests sit deep in the global list (ended contests, smaller
58-
// accounts) were missing from the tab — Julian's @dimensionx
59-
// report. Together with the `useEffect` below that drains pages
60-
// until exhausted, this guarantees the host's contests appear once
61-
// they're anywhere in the result set.
62-
const {
63-
data: trackIds,
64-
isPending,
65-
isFetching,
66-
hasNextPage,
67-
fetchNextPage,
68-
isFetchingNextPage
69-
} = useAllRemixContests({ pageSize: 100 }, { enabled: isFocused })
7031

71-
const allTrackIds = useMemo(() => trackIds ?? [], [trackIds])
72-
73-
// Filter to contests hosted by THIS profile by reading each contest's
74-
// event from the cache (primed by useAllRemixContests). Re-derives on
75-
// every render so newly-arrived pages flow in immediately.
76-
const contestTrackIds = useMemo(() => {
77-
if (hostUserId === undefined) return []
78-
return allTrackIds.filter((trackId) => {
79-
const eventIds = queryClient.getQueryData<ID[]>(
80-
getEventIdsByEntityIdQueryKey({
81-
entityId: trackId,
82-
entityType: EventEntityTypeEnum.Track,
83-
eventType: EventEventTypeEnum.RemixContest
84-
})
85-
)
86-
const eventId = eventIds?.[0]
87-
if (!eventId) return false
88-
const event = queryClient.getQueryData<Event>(getEventQueryKey(eventId))
89-
return event?.userId === hostUserId
90-
})
91-
}, [allTrackIds, hostUserId, queryClient])
32+
const { data: trackIds, isPending } = useUserRemixContests(
33+
{ userId: hostUserId, pageSize: 50 },
34+
{ enabled: isFocused && !!hostUserId }
35+
)
9236

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

10239
if (!hostUserId) {
10340
return null
10441
}
10542

106-
if (
107-
(isPending || isFetchingNextPage || hasNextPage) &&
108-
contestTrackIds.length === 0
109-
) {
43+
if (isPending) {
11044
return (
11145
<Flex justifyContent='center' style={{ marginTop: spacing(6) }}>
11246
<Box style={{ width: 24 }}>
@@ -116,7 +50,7 @@ export const ContestsTab = () => {
11650
)
11751
}
11852

119-
if (!isFetching && contestTrackIds.length === 0) {
53+
if (contestTrackIds.length === 0) {
12054
return <EmptyProfileTile tab='contests' />
12155
}
12256

0 commit comments

Comments
 (0)