diff --git a/.changeset/profile-conditional-contests-tab.md b/.changeset/profile-conditional-contests-tab.md new file mode 100644 index 00000000000..59246f94e66 --- /dev/null +++ b/.changeset/profile-conditional-contests-tab.md @@ -0,0 +1,7 @@ +--- +'@audius/common': patch +'@audius/mobile': patch +'@audius/web': patch +--- + +Hide the profile Contests tab unless the `CONTESTS` feature flag is enabled and the artist hosts at least one remix contest. Previously the desktop and mobile-web profiles always rendered the tab for any artist (ignoring the flag entirely), and the React Native side respected the flag but still showed the tab for artists who don't run any contest — both led to an empty/unreachable destination. Adds a shared `useUserHasRemixContest` hook that paginates the global remix-contest list (matching `ContestsTab`'s page cap) and matches `event.userId` against the host. Direct visits to `/:handle/contests` on a non-qualifying profile fall back to the default tab so the body stays in sync with the (now hidden) tab list. diff --git a/packages/common/src/api/tan-query/events/index.ts b/packages/common/src/api/tan-query/events/index.ts index 00071f4ad8d..d7f7bb75b0f 100644 --- a/packages/common/src/api/tan-query/events/index.ts +++ b/packages/common/src/api/tan-query/events/index.ts @@ -8,6 +8,7 @@ export * from './useEventsByEntityId' export * from './useFollowEvent' export * from './useRemixContest' export * from './useRemixContestWinners' +export * from './useUserHasRemixContest' // 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 new file mode 100644 index 00000000000..548db2ce7b3 --- /dev/null +++ b/packages/common/src/api/tan-query/events/useUserHasRemixContest.ts @@ -0,0 +1,90 @@ +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' + +const PAGE_SIZE = 50 +const MAX_PAGES_TO_LOAD = 5 + +/** + * 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. + */ +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))) + + return { hasContest, isPending: isResolving } +} diff --git a/packages/mobile/src/screens/profile-screen/ProfileTabs/ProfileTabNavigator.tsx b/packages/mobile/src/screens/profile-screen/ProfileTabs/ProfileTabNavigator.tsx index af406eb4d48..eac7b1d08d3 100644 --- a/packages/mobile/src/screens/profile-screen/ProfileTabs/ProfileTabNavigator.tsx +++ b/packages/mobile/src/screens/profile-screen/ProfileTabs/ProfileTabNavigator.tsx @@ -1,6 +1,6 @@ import type { ReactElement } from 'react' -import { useProfileUser } from '@audius/common/api' +import { useProfileUser, useUserHasRemixContest } from '@audius/common/api' import { useFeatureFlag, useIsArtist } from '@audius/common/hooks' import { FeatureFlags } from '@audius/common/services' import { ProfilePageTabs } from '@audius/common/store' @@ -57,6 +57,10 @@ export const ProfileTabNavigator = ({ } const isArtist = useIsArtist(params) const { isEnabled: isContestsEnabled } = useFeatureFlag(FeatureFlags.CONTESTS) + const { hasContest: profileHasContest } = useUserHasRemixContest( + isArtist && isContestsEnabled ? user_id : null + ) + const showContestsTab = isContestsEnabled && profileHasContest const trackScreen = collapsibleTabScreen({ name: ProfilePageTabs.TRACKS, @@ -114,10 +118,10 @@ export const ProfileTabNavigator = ({ {albumsScreen} {playlistsScreen} {repostsScreen} - {/* Contests tab — gated by the same flag as the contests page + - dedicated contest screen. Hidden when CONTESTS is off so the - tab doesn't lead to an unreachable destination. */} - {isContestsEnabled ? contestsScreen : null} + {/* Contests tab — gated by the CONTESTS flag AND on whether this + host actually runs any remix contest. Hidden otherwise so the + tab doesn't lead to an empty/unreachable destination. */} + {showContestsTab ? contestsScreen : null} ) } diff --git a/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx b/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx index 781a091edda..b96970d0177 100644 --- a/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx +++ b/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx @@ -12,12 +12,15 @@ import { useMutedUsers, useProfileTracks, useProfileReposts, + useUserHasRemixContest, getProfileTracksQueryKey, getProfileRepostsQueryKey } from '@audius/common/api' import { useMuteUser } from '@audius/common/context' +import { useFeatureFlag } from '@audius/common/hooks' import { commentsMessages } from '@audius/common/messages' import { Status } from '@audius/common/models' +import { FeatureFlags } from '@audius/common/services' import { ProfilePageTabs } from '@audius/common/store' import { route } from '@audius/common/utils' import { @@ -204,10 +207,23 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => { const profileBasePath = profilePage(handle) + const { isEnabled: isContestsEnabled } = useFeatureFlag(FeatureFlags.CONTESTS) + const { hasContest: profileHasContest } = useUserHasRemixContest( + isArtist && isContestsEnabled ? userId : null + ) + const showContestsTab = isContestsEnabled && profileHasContest + // Determine which tab is active. The URL is the source of truth; activeTab // (from useProfilePage, derived from route params) drives the body render. const defaultTab = isArtist ? ProfilePageTabs.TRACKS : ProfilePageTabs.REPOSTS - const currentTab = activeTab ?? defaultTab + // If a viewer hits /:handle/contests on a profile that doesn't qualify for + // the tab (flag off or host doesn't run any contest), fall back to the + // default so the body matches the (now hidden) tab list. + const rawTab = activeTab ?? defaultTab + const currentTab = + rawTab === ProfilePageTabs.CONTESTS && !showContestsTab + ? defaultTab + : rawTab const tabs = profile ? ( isArtist ? ( @@ -224,9 +240,11 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => { }> {ProfilePageTabs.REPOSTS} - }> - {ProfilePageTabs.CONTESTS} - + {showContestsTab ? ( + }> + {ProfilePageTabs.CONTESTS} + + ) : null} ) : ( didChangeTabsFrom('', key)}> diff --git a/packages/web/src/pages/profile-page/components/mobile/ProfilePage.tsx b/packages/web/src/pages/profile-page/components/mobile/ProfilePage.tsx index fa8dd94009e..902e48d64c3 100644 --- a/packages/web/src/pages/profile-page/components/mobile/ProfilePage.tsx +++ b/packages/web/src/pages/profile-page/components/mobile/ProfilePage.tsx @@ -3,10 +3,13 @@ import { useEffect, useContext, RefObject, useMemo } from 'react' import { useProfileTracks, useProfileReposts, + useUserHasRemixContest, getProfileTracksQueryKey, getProfileRepostsQueryKey } from '@audius/common/api' +import { useFeatureFlag } from '@audius/common/hooks' import { Status, User } from '@audius/common/models' +import { FeatureFlags } from '@audius/common/services' import { ProfilePageTabs } from '@audius/common/store' import { route } from '@audius/common/utils' import { @@ -173,8 +176,21 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => { ) const profileBasePath = profilePage(handle) + const { isEnabled: isContestsEnabled } = useFeatureFlag(FeatureFlags.CONTESTS) + const { hasContest: profileHasContest } = useUserHasRemixContest( + isArtist && isContestsEnabled ? userId : null + ) + const showContestsTab = isContestsEnabled && profileHasContest + const defaultTab = isArtist ? ProfilePageTabs.TRACKS : ProfilePageTabs.REPOSTS - const currentTab = activeTab ?? defaultTab + // Fall back to the default tab when the URL points at /contests but the + // tab itself is hidden (flag off or this host runs no contests). Keeps + // the body in sync with the (now conditional) tab list. + const rawTab = activeTab ?? defaultTab + const currentTab = + rawTab === ProfilePageTabs.CONTESTS && !showContestsTab + ? defaultTab + : rawTab const profileTabs = !profile || isLoading || isEditing ? null : isArtist ? ( @@ -197,9 +213,11 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => { > {ProfilePageTabs.REPOSTS} - }> - {ProfilePageTabs.CONTESTS} - + {showContestsTab ? ( + }> + {ProfilePageTabs.CONTESTS} + + ) : null} ) : (