diff --git a/packages/common/src/api/tan-query/events/index.ts b/packages/common/src/api/tan-query/events/index.ts index d7f7bb75b0f..58487ae1827 100644 --- a/packages/common/src/api/tan-query/events/index.ts +++ b/packages/common/src/api/tan-query/events/index.ts @@ -9,6 +9,7 @@ export * from './useFollowEvent' export * from './useRemixContest' export * from './useRemixContestWinners' export * from './useUserHasRemixContest' +export * from './useUserRemixContests' // Mutations export * from './useCreateEvent' diff --git a/packages/common/src/api/tan-query/events/useUserHasRemixContest.ts b/packages/common/src/api/tan-query/events/useUserHasRemixContest.ts index e458e59f287..ba526ad1adb 100644 --- a/packages/common/src/api/tan-query/events/useUserHasRemixContest.ts +++ b/packages/common/src/api/tan-query/events/useUserHasRemixContest.ts @@ -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( - getEventIdsByEntityIdQueryKey({ - entityId: trackId, - entityType: EventEntityTypeEnum.Track, - eventType: EventEventTypeEnum.RemixContest - }) - ) - const eventId = eventIds?.[0] - if (!eventId) return false - const event = queryClient.getQueryData(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 } } diff --git a/packages/common/src/api/tan-query/events/useUserRemixContests.ts b/packages/common/src/api/tan-query/events/useUserRemixContests.ts new file mode 100644 index 00000000000..9d0a7d0d77d --- /dev/null +++ b/packages/common/src/api/tan-query/events/useUserRemixContests.ts @@ -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 + +/** + * 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 + }) +} diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index 00f6adaae43..1183b8dddee 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -97,6 +97,7 @@ export const QUERY_KEYS = { events: 'events', eventsByEntityId: 'eventsByEntityId', remixContestsList: 'remixContestsList', + userRemixContests: 'userRemixContests', walletOwner: 'walletOwner', tokenPrice: 'tokenPrice', usdcBalance: 'usdcBalance', diff --git a/packages/sdk/src/sdk/api/generated/default/apis/UsersApi.ts b/packages/sdk/src/sdk/api/generated/default/apis/UsersApi.ts index 94ca5d3e120..c2b8fb2e693 100644 --- a/packages/sdk/src/sdk/api/generated/default/apis/UsersApi.ts +++ b/packages/sdk/src/sdk/api/generated/default/apis/UsersApi.ts @@ -46,6 +46,7 @@ import type { PurchasesCountResponse, PurchasesResponse, RelatedArtistResponse, + RemixContestsResponse, RemixersCountResponse, RemixersResponse, Reposts, @@ -139,6 +140,8 @@ import { PurchasesResponseToJSON, RelatedArtistResponseFromJSON, RelatedArtistResponseToJSON, + RemixContestsResponseFromJSON, + RemixContestsResponseToJSON, RemixersCountResponseFromJSON, RemixersCountResponseToJSON, RemixersResponseFromJSON, @@ -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; @@ -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> { + 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 { + const response = await this.getContestsByUserRaw(params, initOverrides); + return await response.value(); + } + /** * @hidden * All users that follow the provided user @@ -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 */ diff --git a/packages/web/src/pages/profile-page/components/desktop/ContestsTab.tsx b/packages/web/src/pages/profile-page/components/desktop/ContestsTab.tsx index 62e2cb38952..b49345b498d 100644 --- a/packages/web/src/pages/profile-page/components/desktop/ContestsTab.tsx +++ b/packages/web/src/pages/profile-page/components/desktop/ContestsTab.tsx @@ -1,7 +1,7 @@ -import { useEffect, useMemo } from 'react' +import { useMemo } from 'react' -import { useAllRemixContests, useRemixContest } from '@audius/common/api' -import { ID, User } from '@audius/common/models' +import { useUserRemixContests } from '@audius/common/api' +import { User } from '@audius/common/models' import { Box, Flex, LoadingSpinner } from '@audius/harmony' import { ContestCard } from 'components/contest-card/ContestCard' @@ -9,8 +9,6 @@ import { ContestCard } from 'components/contest-card/ContestCard' import { EmptyTab } from './EmptyTab' import styles from './ProfilePage.module.css' -const MAX_PAGES_TO_LOAD = 5 - const messages = { emptyContests: 'hosted any contests' } @@ -20,36 +18,14 @@ type ContestsTabProps = { isOwner: boolean } -/** - * Per-row guard: render a `` only when the resolved remix - * contest event for `trackId` is hosted by `hostUserId`. Lets the parent - * tab iterate over the global remix-contest list (which doesn't yet - * support a host-userId filter at the API layer) and keep only the rows - * for this profile. The event has already been primed into the React - * Query cache by `useAllRemixContests`, so this is a synchronous cache - * read in practice. - */ -const HostedContestCard = ({ - trackId, - hostUserId -}: { - trackId: ID - hostUserId: ID -}) => { - const { data: contest } = useRemixContest(trackId) - if (!contest || contest.userId !== hostUserId) return null - return -} - /** * Profile "Contests" tab. Lists the contests hosted by this profile as a * grid of `ContestCard`s that link out to the dedicated contest page. * Matches Figma 2864-13286. * - * The discovery endpoint behind `useAllRemixContests` doesn't yet - * support filtering by host userId, so we paginate the global list and - * filter client-side by `event.userId === profile.user_id`. Active - * contests come first (by soonest-ending end_date), then ended. + * Calls `GET /v1/users/{id}/contests` (via `useUserRemixContests`), which + * returns only this artist's contests with active first (by soonest-ending + * end_date) followed by ended. */ export const ContestsTab = ({ profile }: ContestsTabProps) => { const { user_id: hostUserId, name } = profile @@ -57,27 +33,14 @@ export const ContestsTab = ({ profile }: ContestsTabProps) => { const { data: trackIds, isPending, - isFetching, - hasNextPage, - isFetchingNextPage, - fetchNextPage - } = useAllRemixContests({ pageSize: 50 }) + isFetching + } = useUserRemixContests({ + userId: hostUserId, + pageSize: 50 + }) const contestTrackIds = useMemo(() => trackIds ?? [], [trackIds]) - // The discovery endpoint can't filter by host userId yet, so the - // client filters globally fetched pages. Without auto-pagination an - // artist whose contests aren't on the first 50 rows of the global - // list (active first, then ended) reads as "no contests" — including - // ended-contest-only artists. Pull additional pages opportunistically - // up to a safety cap so a typical profile resolves correctly. - const loadedPages = trackIds ? Math.ceil(trackIds.length / 50) : 0 - useEffect(() => { - if (hasNextPage && !isFetchingNextPage && loadedPages < MAX_PAGES_TO_LOAD) { - fetchNextPage() - } - }, [hasNextPage, isFetchingNextPage, loadedPages, fetchNextPage]) - if (isPending && contestTrackIds.length === 0) { return ( @@ -88,10 +51,6 @@ export const ContestsTab = ({ profile }: ContestsTabProps) => { ) } - // The list itself can be non-empty while every row gets filtered out - // (none belong to this host), so the empty-state check happens both - // before and after filtering. We can't filter ahead of render without - // breaking React Query hook ordering — see HostedContestCard. if (!isFetching && contestTrackIds.length === 0) { return ( @@ -118,11 +77,7 @@ export const ContestsTab = ({ profile }: ContestsTabProps) => { }} > {contestTrackIds.map((trackId) => ( - + ))} ) diff --git a/packages/web/src/pages/profile-page/components/mobile/ContestsTab.tsx b/packages/web/src/pages/profile-page/components/mobile/ContestsTab.tsx index 9c564e59b6a..19bd097414b 100644 --- a/packages/web/src/pages/profile-page/components/mobile/ContestsTab.tsx +++ b/packages/web/src/pages/profile-page/components/mobile/ContestsTab.tsx @@ -1,43 +1,27 @@ -import { useEffect, useMemo } from 'react' +import { useMemo } from 'react' -import { useAllRemixContests, useRemixContest } from '@audius/common/api' -import { ID, User } from '@audius/common/models' +import { useUserRemixContests } from '@audius/common/api' +import { User } from '@audius/common/models' import { Box, Flex, LoadingSpinner } from '@audius/harmony' import { ContestCard } from 'components/contest-card/ContestCard' import { EmptyTab } from './EmptyTab' -const MAX_PAGES_TO_LOAD = 5 - type ContestsTabProps = { profile: User isOwner: boolean } -/** - * Per-row guard: render a `` only when the resolved remix - * contest event for `trackId` is hosted by `hostUserId`. See the desktop - * ContestsTab for the full rationale; the mobile component is a thin - * wrapper around the same cards with a stacked single-column layout. - */ -const HostedContestCard = ({ - trackId, - hostUserId -}: { - trackId: ID - hostUserId: ID -}) => { - const { data: contest } = useRemixContest(trackId) - if (!contest || contest.userId !== hostUserId) return null - return -} - /** * Profile "Contests" tab on mobile. Lists contests hosted by this * profile as a stacked grid of `ContestCard`s. Matches Figma 2864-13286 * (the desktop layout collapses to a single column on narrow shells — * mobile reuses the same card so the visual treatment stays consistent). + * + * Calls `GET /v1/users/{id}/contests` (via `useUserRemixContests`), which + * returns only this artist's contests with active first (by soonest-ending + * end_date) followed by ended. */ export const ContestsTab = ({ profile, isOwner }: ContestsTabProps) => { const { user_id: hostUserId, name } = profile @@ -45,26 +29,14 @@ export const ContestsTab = ({ profile, isOwner }: ContestsTabProps) => { const { data: trackIds, isPending, - isFetching, - hasNextPage, - isFetchingNextPage, - fetchNextPage - } = useAllRemixContests({ pageSize: 50 }) + isFetching + } = useUserRemixContests({ + userId: hostUserId, + pageSize: 50 + }) const contestTrackIds = useMemo(() => trackIds ?? [], [trackIds]) - // Auto-paginate the global contest list (the discovery endpoint - // doesn't filter by host yet) up to a safety cap. Without this, an - // artist whose hosted contests sit beyond the first page reads as - // "no contests" until the user scrolls — and ended-only artists - // never showed up. - const loadedPages = trackIds ? Math.ceil(trackIds.length / 50) : 0 - useEffect(() => { - if (hasNextPage && !isFetchingNextPage && loadedPages < MAX_PAGES_TO_LOAD) { - fetchNextPage() - } - }, [hasNextPage, isFetchingNextPage, loadedPages, fetchNextPage]) - if (isPending && contestTrackIds.length === 0) { return ( @@ -88,11 +60,7 @@ export const ContestsTab = ({ profile, isOwner }: ContestsTabProps) => { return ( {contestTrackIds.map((trackId) => ( - + ))} ) diff --git a/packages/web/src/pages/search-explore-page/components/desktop/SearchExplorePage.tsx b/packages/web/src/pages/search-explore-page/components/desktop/SearchExplorePage.tsx index 6b0f9aa6690..14bf13436e8 100644 --- a/packages/web/src/pages/search-explore-page/components/desktop/SearchExplorePage.tsx +++ b/packages/web/src/pages/search-explore-page/components/desktop/SearchExplorePage.tsx @@ -140,21 +140,8 @@ const SearchExplorePage = ({ newParams.delete('query') } setSearchParams(newParams, { replace: true }) - } else if (categoryKey === SearchTabs.ALL.toLowerCase()) { - // clear filters when searching all - const newParams = new URLSearchParams() - if (debouncedValue) { - newParams.set('query', debouncedValue) - } - setSearchParams(newParams, { replace: true }) } - }, [ - debouncedValue, - setSearchParams, - searchParams, - previousDebouncedValue, - categoryKey - ]) + }, [debouncedValue, setSearchParams, searchParams, previousDebouncedValue]) const filterKeys: string[] = categories[categoryKey].filters diff --git a/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx b/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx index 8a8cb74c7d2..410af39ebc3 100644 --- a/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx +++ b/packages/web/src/pages/search-explore-page/components/mobile/SearchExplorePage.tsx @@ -115,21 +115,8 @@ const SearchExplorePage = ({ newParams.delete('query') } setSearchParams(newParams, { replace: true }) - } else if (categoryKey === SearchTabs.ALL.toLowerCase()) { - // clear filters when searching all - const newParams = new URLSearchParams() - if (debouncedValue) { - newParams.set('query', debouncedValue) - } - setSearchParams(newParams, { replace: true }) } - }, [ - debouncedValue, - setSearchParams, - searchParams, - previousDebouncedValue, - categoryKey - ]) + }, [debouncedValue, setSearchParams, searchParams, previousDebouncedValue]) const { setCenter, setRight } = useContext(NavContext)! const { setHeader } = useContext(HeaderContext)