From aca55442fc9d7f6116bd9d0d5e9b04a815648980 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 01:03:17 +0000 Subject: [PATCH 1/3] profile: only show Contests tab when host has a contest and flag is on The Contests tab was rendered for every artist profile, regardless of whether the CONTESTS feature flag was on or whether the artist actually hosted any remix contest. The desktop and mobile-web tab lists missed the flag check entirely; the React Native side gated on the flag but not on host activity. Add useUserHasRemixContest, a shared hook that paginates the global remix-contest list (with the same MAX_PAGES_TO_LOAD cap as ContestsTab) and matches event.userId against the host. Wire it into the desktop, mobile-web, and React Native profile screens so the tab only appears when the flag is enabled AND the artist hosts at least one contest. Also fall back to the default tab if a viewer hits /:handle/contests on a profile that no longer qualifies. https://claude.ai/code/session_015jtkBQyrvnULHbEDxgM9gc --- .../common/src/api/tan-query/events/index.ts | 1 + .../events/useUserHasRemixContest.ts | 90 +++++++++++++++++++ .../ProfileTabs/ProfileTabNavigator.tsx | 14 +-- .../components/desktop/ProfilePage.tsx | 24 ++++- .../components/mobile/ProfilePage.tsx | 24 ++++- 5 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 packages/common/src/api/tan-query/events/useUserHasRemixContest.ts 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..0962f9e3aa6 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,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 + // 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 +238,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..4f8ad539899 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,19 @@ 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 +211,11 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => { > {ProfilePageTabs.REPOSTS} - }> - {ProfilePageTabs.CONTESTS} - + {showContestsTab ? ( + }> + {ProfilePageTabs.CONTESTS} + + ) : null} ) : ( Date: Thu, 7 May 2026 01:04:09 +0000 Subject: [PATCH 2/3] chore: add changeset for conditional Contests tab https://claude.ai/code/session_015jtkBQyrvnULHbEDxgM9gc --- .changeset/profile-conditional-contests-tab.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/profile-conditional-contests-tab.md 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. From c5d8756430be5262daee20551482b10455da710d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 01:07:26 +0000 Subject: [PATCH 3/3] chore: fix prettier wrapping for currentTab ternary https://claude.ai/code/session_015jtkBQyrvnULHbEDxgM9gc --- .../src/pages/profile-page/components/desktop/ProfilePage.tsx | 4 +++- .../src/pages/profile-page/components/mobile/ProfilePage.tsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) 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 0962f9e3aa6..b96970d0177 100644 --- a/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx +++ b/packages/web/src/pages/profile-page/components/desktop/ProfilePage.tsx @@ -221,7 +221,9 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => { // default so the body matches the (now hidden) tab list. const rawTab = activeTab ?? defaultTab const currentTab = - rawTab === ProfilePageTabs.CONTESTS && !showContestsTab ? defaultTab : rawTab + rawTab === ProfilePageTabs.CONTESTS && !showContestsTab + ? defaultTab + : rawTab const tabs = profile ? ( isArtist ? ( 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 4f8ad539899..902e48d64c3 100644 --- a/packages/web/src/pages/profile-page/components/mobile/ProfilePage.tsx +++ b/packages/web/src/pages/profile-page/components/mobile/ProfilePage.tsx @@ -188,7 +188,9 @@ const ProfilePage = ({ containerRef }: ProfilePageProps) => { // the body in sync with the (now conditional) tab list. const rawTab = activeTab ?? defaultTab const currentTab = - rawTab === ProfilePageTabs.CONTESTS && !showContestsTab ? defaultTab : rawTab + rawTab === ProfilePageTabs.CONTESTS && !showContestsTab + ? defaultTab + : rawTab const profileTabs = !profile || isLoading || isEditing ? null : isArtist ? (