diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index f3b5d6ec850..82f5d93f7cd 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -86,6 +86,7 @@ export * from './tan-query/search/useSearchAutocomplete' export * from './tan-query/search/useSearchResults' export * from './tan-query/search/useTopTags' export * from './tan-query/search/useGenreSuggestions' +export * from './tan-query/search/usePopularGenres' // Tracks export * from './tan-query/tracks/useDeleteTrack' diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index baf882403a2..7e8b20656fe 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -69,6 +69,7 @@ export const QUERY_KEYS = { trackHistory: 'trackHistory', topTags: 'topTags', genreSuggestions: 'genreSuggestions', + popularGenres: 'popularGenres', feed: 'feed', feedTab: 'feedTab', feedFilter: 'feedFilter', diff --git a/packages/common/src/api/tan-query/search/usePopularGenres.ts b/packages/common/src/api/tan-query/search/usePopularGenres.ts new file mode 100644 index 00000000000..f491d9806bf --- /dev/null +++ b/packages/common/src/api/tan-query/search/usePopularGenres.ts @@ -0,0 +1,53 @@ +import { useQuery } from '@tanstack/react-query' + +import { + GenreSuggestion, + POPULAR_GENRES_LIMIT, + mergeGenreSuggestions, + normalizeGenre +} from '~/utils/genres' + +import { QUERY_KEYS } from '../queryKeys' +import { QueryKey, QueryOptions } from '../types' +import { useQueryContext } from '../utils' + +const STALE_TIME = 15 * 60 * 1000 // 15 minutes + +export const getPopularGenresQueryKey = () => + [QUERY_KEYS.popularGenres] as unknown as QueryKey + +/** + * Fetches the currently top-ranked genres from /v1/genres/popular and merges + * them with the static genre list. Popular (ranked) genres carry a `count`; + * use {@link getTrendingGenreSuggestions} to narrow to the ranked subset. + * Powers the trending genre filter and the Explore "Trending Genres" section. + */ +export const usePopularGenres = (options?: QueryOptions) => { + const { audiusSdk } = useQueryContext() + + return useQuery({ + queryKey: getPopularGenresQueryKey(), + queryFn: async () => { + try { + const sdk = await audiusSdk() + const json = await sdk.genres.getPopularGenres({ + limit: POPULAR_GENRES_LIMIT + }) + const communityGenres = + json.data?.map((genre) => ({ + label: normalizeGenre(genre.name), + value: genre.name, + count: genre.count + })) ?? [] + + return mergeGenreSuggestions(communityGenres) + } catch { + return mergeGenreSuggestions([]) + } + }, + staleTime: STALE_TIME, + gcTime: STALE_TIME, + ...options, + enabled: options?.enabled !== false + }) +} diff --git a/packages/common/src/messages/explore.ts b/packages/common/src/messages/explore.ts index a71f9af5432..76551450db9 100644 --- a/packages/common/src/messages/explore.ts +++ b/packages/common/src/messages/explore.ts @@ -15,6 +15,7 @@ export const exploreMessages = { bestSellingAlbums: 'Best Selling Albums', exploreByMood: (category?: string) => `Explore${category ? ` ${category}` : ''} by Mood`, + trendingGenres: 'Trending Genres', quickSearch: 'Quick Search', activeDiscussions: 'Active Discussions', mostShared: 'Most Shared Tracks This Week', diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts index b67005904fd..d2fbb4e0c8a 100644 --- a/packages/common/src/models/Analytics.ts +++ b/packages/common/src/models/Analytics.ts @@ -1313,6 +1313,7 @@ export type ExploreSectionName = | 'Fan Clubs' | 'Featured Remix Contests' | 'Underground Trending Tracks' + | 'Trending Genres' | 'Artist Spotlight' | 'Label Spotlight' | 'Active Discussions' diff --git a/packages/common/src/utils/genres.ts b/packages/common/src/utils/genres.ts index d54294d0362..2d5aaa6ce51 100644 --- a/packages/common/src/utils/genres.ts +++ b/packages/common/src/utils/genres.ts @@ -121,6 +121,23 @@ export const toTrendingGenre = (value: string | null): SDKGenre | null => { return convertGenreLabelToValue(value as GenreLabel) } +/** + * Converts a genre label from the trending filter UI into the value stored in + * trending state. Unlike {@link toTrendingGenre}, this accepts freeform / + * community genres that are not part of the static {@link GENRES} list, so the + * dynamic popular-genres filter can select long-tail genres. Strips the + * Electronic prefix for the static electronic subgenres and maps the + * {@link ALL_GENRES} sentinel / empty values to null. + */ +export const toTrendingGenreValue = (value: string | null): SDKGenre | null => { + if (value === null || value === '' || value === ALL_GENRES) return null + return ( + value.startsWith(ELECTRONIC_PREFIX) + ? value.slice(ELECTRONIC_PREFIX.length) + : value + ) as SDKGenre +} + const NEWLY_ADDED_GENRES: string[] = [] export const TRENDING_GENRES = GENRES.filter( @@ -206,3 +223,28 @@ export const mergeGenreSuggestions = ( return a.label.localeCompare(b.label) }) } + +/** Number of top genres to request from the popular-genres endpoint. */ +export const POPULAR_GENRES_LIMIT = 25 + +/** + * Selects the genre suggestions to display in the trending genre filter for a + * given search query. With no query, returns the popular/ranked genres (those + * with a recent-activity count) so the top genres are shown by default; while + * searching, matches labels across the full set so long-tail genres remain + * discoverable. Falls back to the full list when no popular genres are + * available (e.g. the popular-genres request failed). + */ +export const getTrendingGenreSuggestions = ( + suggestions: GenreSuggestion[], + searchValue = '' +): GenreSuggestion[] => { + const query = searchValue.trim().toLowerCase() + if (query) { + return suggestions.filter((genre) => + genre.label.toLowerCase().includes(query) + ) + } + const popular = suggestions.filter((genre) => genre.count !== undefined) + return popular.length > 0 ? popular : suggestions +} diff --git a/packages/mobile/src/screens/explore-screen/components/ExploreContent.tsx b/packages/mobile/src/screens/explore-screen/components/ExploreContent.tsx index ce5ac5007fc..c91d1ec2011 100644 --- a/packages/mobile/src/screens/explore-screen/components/ExploreContent.tsx +++ b/packages/mobile/src/screens/explore-screen/components/ExploreContent.tsx @@ -16,6 +16,7 @@ import { LabelSpotlight } from './LabelSpotlight' import { NewAlbumReleases } from './NewAlbumReleases' import { RecentlyPlayedTracks } from './RecentlyPlayed' import { TopAlbumsThisMonth } from './TopAlbumsThisMonth' +import { TrendingGenres } from './TrendingGenres' export const ExploreContent = () => { const [category] = useSearchCategory() @@ -32,6 +33,7 @@ export const ExploreContent = () => { {showTrackContent && showUserContextualContent && } {showPlaylistContent && } + {showTrackContent && } {showAlbumContent && } {showAlbumContent && } {showAlbumContent && } diff --git a/packages/mobile/src/screens/explore-screen/components/TrendingGenres.tsx b/packages/mobile/src/screens/explore-screen/components/TrendingGenres.tsx new file mode 100644 index 00000000000..e047cba5f1e --- /dev/null +++ b/packages/mobile/src/screens/explore-screen/components/TrendingGenres.tsx @@ -0,0 +1,86 @@ +import React, { useCallback } from 'react' + +import { usePopularGenres } from '@audius/common/api' +import { exploreMessages as messages } from '@audius/common/messages' +import { trendingPageActions } from '@audius/common/store' +import { toTrendingGenreValue } from '@audius/common/utils' +import { ScrollView } from 'react-native' +import { useDispatch } from 'react-redux' + +import { Flex, SelectablePill, Skeleton } from '@audius/harmony-native' +import { useNavigation } from 'app/hooks/useNavigation' + +import { useExploreSectionTracking } from '../hooks/useExploreSectionTracking' + +import { ExploreSection } from './ExploreSection' + +const { setTrendingGenre } = trendingPageActions + +const SKELETON_COUNT = 8 +const MAX_GENRES = 10 + +export const TrendingGenres = () => { + const { inView, InViewWrapper } = useExploreSectionTracking('Trending Genres') + const dispatch = useDispatch() + const navigation = useNavigation() + + const { data: genreSuggestions, isPending } = usePopularGenres({ + enabled: inView + }) + + const handleGenrePress = useCallback( + (genre: string) => { + dispatch(setTrendingGenre(toTrendingGenreValue(genre))) + navigation.navigate('trending', { screen: 'Trending' }) + }, + [dispatch, navigation] + ) + + // Only the ranked (popular) genres carry a count; the merged static genres + // do not. Show the top ranked genres in popularity order. + const topGenres = (genreSuggestions ?? []) + .filter((genre) => genre.count !== undefined) + .slice(0, MAX_GENRES) + + const showSkeleton = !inView || isPending + + // Hide the section gracefully when there are no popular genres (empty + // response or a failed request that fell back to the static list). + if (!showSkeleton && topGenres.length === 0) { + return null + } + + return ( + + + + + {showSkeleton + ? Array.from({ length: SKELETON_COUNT }).map((_, i) => ( + + )) + : topGenres.map((genre) => ( + handleGenrePress(genre.value)} + /> + ))} + + + + + ) +} diff --git a/packages/mobile/src/screens/trending-screen/TrendingFilterDrawer.tsx b/packages/mobile/src/screens/trending-screen/TrendingFilterDrawer.tsx index ddb372d480f..eb07f75575e 100644 --- a/packages/mobile/src/screens/trending-screen/TrendingFilterDrawer.tsx +++ b/packages/mobile/src/screens/trending-screen/TrendingFilterDrawer.tsx @@ -1,16 +1,15 @@ import { useCallback, useMemo, useState } from 'react' +import { usePopularGenres } from '@audius/common/api' import { modalsActions, trendingPageActions, trendingPageSelectors } from '@audius/common/store' import { - type Genre, - ELECTRONIC_PREFIX, - ELECTRONIC_SUBGENRES, - GENRES, - ALL_GENRES + ALL_GENRES, + getTrendingGenreSuggestions, + toTrendingGenreValue } from '@audius/common/utils' import { FlatList, @@ -47,8 +46,6 @@ const messages = { selected: 'Selected' } -const trendingGenres = [ALL_GENRES, ...GENRES] - const useStyles = makeStyles(({ palette, spacing, typography }) => ({ root: { flex: 1 @@ -90,6 +87,7 @@ export const TrendingFilterDrawer = () => { const trendingGenre = useSelector(getTrendingGenre) ?? ALL_GENRES const { onClose } = useDrawerState(MODAL_NAME) const dispatch = useDispatch() + const { data: genreSuggestions = [] } = usePopularGenres() const handleBack = useCallback(() => { dispatch(setVisibility({ modal: MODAL_NAME, visible: false })) @@ -122,20 +120,20 @@ export const TrendingFilterDrawer = () => { ) const genres = useMemo(() => { - const searchValueLower = searchValue.toLowerCase() - return trendingGenres.filter((genre) => - genre.toLowerCase().includes(searchValueLower) - ) - }, [searchValue]) + // Show the top popular genres by default; while searching, match across the + // full set so long-tail genres remain discoverable. + const filtered = getTrendingGenreSuggestions( + genreSuggestions, + searchValue + ).map((genre) => genre.label) + const query = searchValue.trim().toLowerCase() + const includeAllGenres = !query || ALL_GENRES.toLowerCase().includes(query) + return includeAllGenres ? [ALL_GENRES, ...filtered] : filtered + }, [genreSuggestions, searchValue]) const handleSelect = useCallback( (genre: string) => { - const trimmedGenre = - genre === ALL_GENRES - ? null - : (genre.replace(ELECTRONIC_PREFIX, '') as Genre) - - dispatch(setTrendingGenre(trimmedGenre)) + dispatch(setTrendingGenre(toTrendingGenreValue(genre))) Keyboard.dismiss() onClose() }, @@ -168,8 +166,9 @@ export const TrendingFilterDrawer = () => { ItemSeparatorComponent={Divider} renderItem={({ item: genre }) => { const isSelected = - ELECTRONIC_SUBGENRES[genre] === trendingGenre || - genre === trendingGenre + genre === ALL_GENRES + ? trendingGenre === ALL_GENRES + : toTrendingGenreValue(genre) === trendingGenre return ( handleSelect(genre)}> diff --git a/packages/web/src/components/trending-genre-selection/TrendingGenreSelectionPage.tsx b/packages/web/src/components/trending-genre-selection/TrendingGenreSelectionPage.tsx index 37eb65cc776..14bfc69aa18 100644 --- a/packages/web/src/components/trending-genre-selection/TrendingGenreSelectionPage.tsx +++ b/packages/web/src/components/trending-genre-selection/TrendingGenreSelectionPage.tsx @@ -1,3 +1,4 @@ +import { usePopularGenres } from '@audius/common/api' import { TimeRange } from '@audius/common/models' import { trendingPageActions, @@ -5,8 +6,8 @@ import { } from '@audius/common/store' import { Genre, - TRENDING_GENRES, - toTrendingGenre, + getTrendingGenreSuggestions, + toTrendingGenreValue, route } from '@audius/common/utils' import { connect } from 'react-redux' @@ -35,14 +36,23 @@ const ConnectedTrendingGenreSelectionPage = ({ setTrendingTimeRange, goToTrending }: ConnectedTrendingGenreSelectionPageProps) => { + const { data: genreSuggestions = [] } = usePopularGenres() + const setTrimmedGenre = (genre: string | null) => { - setTrendingGenre(toTrendingGenre(genre)) + setTrendingGenre(toTrendingGenreValue(genre)) setTrendingTimeRange(timeRange) goToTrending() } + + const allGenres = genreSuggestions.map((g) => g.label) + const topGenres = getTrendingGenreSuggestions(genreSuggestions).map( + (g) => g.label + ) + return ( diff --git a/packages/web/src/components/trending-genre-selection/components/TrendingGenreSelectionPage.tsx b/packages/web/src/components/trending-genre-selection/components/TrendingGenreSelectionPage.tsx index 638c643f32d..5cb044f796e 100644 --- a/packages/web/src/components/trending-genre-selection/components/TrendingGenreSelectionPage.tsx +++ b/packages/web/src/components/trending-genre-selection/components/TrendingGenreSelectionPage.tsx @@ -10,6 +10,7 @@ type TrendingGenreSelectionPageProps = { selectedGenre: string | null didSelectGenre: (genre: string | null) => void genres: string[] + topGenres?: string[] } const messages = { @@ -19,7 +20,8 @@ const messages = { const TrendingGenreSelectionPage = ({ selectedGenre, didSelectGenre, - genres + genres, + topGenres }: TrendingGenreSelectionPageProps) => { const { setLeft, setCenter, setRight } = useContext(NavContext)! @@ -33,6 +35,7 @@ const TrendingGenreSelectionPage = ({ }, + { + key: 'trendingGenres', + shouldRender: showTrackContent, + element: + }, { key: 'topAlbumsThisMonth', shouldRender: showAlbumContent, diff --git a/packages/web/src/pages/search-explore-page/components/desktop/TrendingGenresSection.tsx b/packages/web/src/pages/search-explore-page/components/desktop/TrendingGenresSection.tsx new file mode 100644 index 00000000000..0fe7965bf92 --- /dev/null +++ b/packages/web/src/pages/search-explore-page/components/desktop/TrendingGenresSection.tsx @@ -0,0 +1,72 @@ +import { useCallback } from 'react' + +import { usePopularGenres } from '@audius/common/api' +import { exploreMessages as messages } from '@audius/common/messages' +import { route } from '@audius/common/utils' +import { Flex, SelectablePill, Text } from '@audius/harmony' +import { useNavigate } from 'react-router' + +import Skeleton from 'components/skeleton/Skeleton' +import { useIsMobile } from 'hooks/useIsMobile' + +import { useExploreSectionTracking } from './useExploreSectionTracking' + +const SKELETON_COUNT = 10 +const MAX_GENRES = 10 + +export const TrendingGenresSection = () => { + const { ref, inView } = useExploreSectionTracking('Trending Genres') + const navigate = useNavigate() + const isMobile = useIsMobile() + + const { data: genreSuggestions, isPending } = usePopularGenres({ + enabled: inView + }) + + const handleGenrePress = useCallback( + (genre: string) => { + navigate(`${route.TRENDING_PAGE}?genre=${encodeURIComponent(genre)}`) + }, + [navigate] + ) + + // Only the ranked (popular) genres carry a count; the merged static genres + // do not. Show the top ranked genres in popularity order. + const topGenres = (genreSuggestions ?? []) + .filter((genre) => genre.count !== undefined) + .slice(0, MAX_GENRES) + + const showSkeleton = !inView || isPending + + // Hide the section gracefully when there are no popular genres (empty + // response or a failed request that fell back to the static list). + if (!showSkeleton && topGenres.length === 0) { + return null + } + + return ( + + + {messages.trendingGenres} + + + {showSkeleton + ? Array.from({ length: SKELETON_COUNT }).map((_, i) => ( + + )) + : topGenres.map((genre) => ( + handleGenrePress(genre.value)} + /> + ))} + + + ) +} diff --git a/packages/web/src/pages/trending-page/components/GenreSelectionList.tsx b/packages/web/src/pages/trending-page/components/GenreSelectionList.tsx index 2868a55afde..6896b20cc8a 100644 --- a/packages/web/src/pages/trending-page/components/GenreSelectionList.tsx +++ b/packages/web/src/pages/trending-page/components/GenreSelectionList.tsx @@ -34,6 +34,12 @@ const GenreButton = ({ type GenreSelectionListProps = { genres: string[] + /** + * Genres shown by default (no active search). When provided, `genres` is + * treated as the searchable superset so the top genres display first while + * long-tail genres remain discoverable via search. + */ + topGenres?: string[] didSelectGenre: (genre: string | null) => void selectedGenre: string | null containerClassName?: string @@ -52,6 +58,7 @@ const messages = { // trending on mobile and desktop. const GenreSelectionList = ({ genres, + topGenres, didSelectGenre, selectedGenre, containerClassName, @@ -64,7 +71,10 @@ const GenreSelectionList = ({ didSelectGenre, { selectedGenre } ) - const filteredGenreList = genres.filter((g) => + // Show the top genres by default; search across the full list so long-tail + // genres remain discoverable. + const sourceGenres = !searchValue.trim() && topGenres ? topGenres : genres + const filteredGenreList = sourceGenres.filter((g) => g.toLowerCase().includes(searchValue.toLowerCase()) ) const selectedIndex = diff --git a/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx b/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx index b968f594bed..b36945235fe 100644 --- a/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx +++ b/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx @@ -5,19 +5,15 @@ import { getTrendingUndergroundQueryKey, TRENDING_INITIAL_PAGE_SIZE, useTrending, - useTrendingUnderground + useTrendingUnderground, + usePopularGenres } from '@audius/common/api' import { Name, TimeRange } from '@audius/common/models' import { trendingPageActions, trendingPageSelectors } from '@audius/common/store' -import { - getCanonicalName, - route, - toTrendingGenre, - TRENDING_GENRES -} from '@audius/common/utils' +import { route, toTrendingGenreValue } from '@audius/common/utils' import { FilterButton, Flex, @@ -58,6 +54,7 @@ const messages = { thisMonth: 'This Month', allTime: 'All Time', allGenres: 'All Genres', + searchGenres: 'Search Genres', genres: 'Genres', endOfLineupDescription: "Looks like you've reached the end of this list..." } @@ -116,6 +113,7 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => { const trendingGenre = useSelector(getTrendingGenre) const trendingTimeRange = useSelector(getTrendingTimeRange) + const { data: genreSuggestions = [] } = usePopularGenres() // Desktop viewports + fast trackpad / wheel scroll need bigger pages than // the shared default (mobile-tuned) so successive load-mores keep up with a @@ -216,7 +214,11 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => { const setTrendingGenre = useCallback( (genre: string | null) => { - dispatch(trendingPageActions.setTrendingGenre(toTrendingGenre(genre))) + // toTrendingGenreValue keeps freeform/community genres (from the dynamic + // popular-genres filter) while mapping the all-genres sentinel to null. + dispatch( + trendingPageActions.setTrendingGenre(toTrendingGenreValue(genre)) + ) }, [dispatch] ) @@ -280,11 +282,13 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => { { label: messages.allTime, value: TimeRange.ALL_TIME } ] + // Popular genres (ranked by recent activity) lead the list, followed by the + // long-tail static genres; all are searchable via the filter input. const genreOptions = [ { label: messages.allGenres, value: 'all' }, - ...TRENDING_GENRES.map((g) => ({ - label: getCanonicalName(g), - value: g + ...genreSuggestions.map((g) => ({ + label: g.label, + value: g.value })) ] @@ -320,6 +324,11 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => { variant='replaceLabel' onChange={handleGenreChange} options={genreOptions} + showFilterInput + filterInputProps={{ + label: messages.searchGenres, + placeholder: messages.searchGenres + }} virtualized menuProps={{ maxHeight: 320, width: 240 }} /> @@ -372,6 +381,11 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => { variant='replaceLabel' onChange={handleGenreChange} options={genreOptions} + showFilterInput + filterInputProps={{ + label: messages.searchGenres, + placeholder: messages.searchGenres + }} virtualized menuProps={{ maxHeight: 320, width: 240 }} /> diff --git a/packages/web/src/pages/trending-page/utils.ts b/packages/web/src/pages/trending-page/utils.ts index e9ad8fd6ff1..e43151e7241 100644 --- a/packages/web/src/pages/trending-page/utils.ts +++ b/packages/web/src/pages/trending-page/utils.ts @@ -1,5 +1,5 @@ import { TimeRange } from '@audius/common/models' -import { GENRES, Genre } from '@audius/common/utils' +import { ALL_GENRES, Genre } from '@audius/common/utils' import { URL_PARAM_KEYS } from './constants' @@ -37,10 +37,14 @@ export const isValidWinnersWeek = (week: string | null): boolean => week !== null && WEEK_YYYY_MM_DD_REGEX.test(week) /** - * Validate if a genre string is a valid genre + * Validate if a genre string is usable as a trending filter. Permissive by + * design: now that freeform genres are supported, the canonical validation + * happens server-side via normalization. We accept any non-empty value that + * isn't the {@link ALL_GENRES} sentinel so freeform genres in the URL param + * (e.g. `?genre=Hyper+Pop`) restore on page load instead of being dropped. */ export const isValidGenre = (genre: string | null): boolean => { - return genre !== null && Object.values(GENRES).includes(genre as any) + return genre !== null && genre.trim() !== '' && genre !== ALL_GENRES } /**