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
7 changes: 7 additions & 0 deletions .changeset/profile-conditional-contests-tab.md
Original file line number Diff line number Diff line change
@@ -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.
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
Expand Up @@ -8,6 +8,7 @@ export * from './useEventsByEntityId'
export * from './useFollowEvent'
export * from './useRemixContest'
export * from './useRemixContestWinners'
export * from './useUserHasRemixContest'

// Mutations
export * from './useCreateEvent'
Expand Down
90 changes: 90 additions & 0 deletions packages/common/src/api/tan-query/events/useUserHasRemixContest.ts
Original file line number Diff line number Diff line change
@@ -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<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
})
}, [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 }
}
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}
</CollapsibleTabNavigator>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ? (
Expand All @@ -224,9 +240,11 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => {
<Tab to={`${profileBasePath}/reposts`} icon={<IconReposts />}>
{ProfilePageTabs.REPOSTS}
</Tab>
<Tab to={`${profileBasePath}/contests`} icon={<IconTrophy />}>
{ProfilePageTabs.CONTESTS}
</Tab>
{showContestsTab ? (
<Tab to={`${profileBasePath}/contests`} icon={<IconTrophy />}>
{ProfilePageTabs.CONTESTS}
</Tab>
) : null}
</TabList>
) : (
<TabList onTabClick={(key) => didChangeTabsFrom('', key)}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ? (
Expand All @@ -197,9 +213,11 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => {
>
{ProfilePageTabs.REPOSTS}
</Tab>
<Tab to={`${profileBasePath}/contests`} icon={<IconTrophy />}>
{ProfilePageTabs.CONTESTS}
</Tab>
{showContestsTab ? (
<Tab to={`${profileBasePath}/contests`} icon={<IconTrophy />}>
{ProfilePageTabs.CONTESTS}
</Tab>
) : null}
</TabList>
) : (
<TabList
Expand Down
Loading