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
1 change: 1 addition & 0 deletions packages/common/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/api/tan-query/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const QUERY_KEYS = {
trackHistory: 'trackHistory',
topTags: 'topTags',
genreSuggestions: 'genreSuggestions',
popularGenres: 'popularGenres',
feed: 'feed',
feedTab: 'feedTab',
feedFilter: 'feedFilter',
Expand Down
53 changes: 53 additions & 0 deletions packages/common/src/api/tan-query/search/usePopularGenres.ts
Original file line number Diff line number Diff line change
@@ -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<GenreSuggestion[]>

/**
* 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
})
}
1 change: 1 addition & 0 deletions packages/common/src/messages/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/models/Analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1313,6 +1313,7 @@ export type ExploreSectionName =
| 'Fan Clubs'
| 'Featured Remix Contests'
| 'Underground Trending Tracks'
| 'Trending Genres'
| 'Artist Spotlight'
| 'Label Spotlight'
| 'Active Discussions'
Expand Down
42 changes: 42 additions & 0 deletions packages/common/src/utils/genres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -32,6 +33,7 @@ export const ExploreContent = () => {
<Flex gap='2xl' pt='s' pb={150} ph='l'>
{showTrackContent && showUserContextualContent && <ForYouTracks />}
{showPlaylistContent && <FeaturedPlaylists />}
{showTrackContent && <TrendingGenres />}
{showAlbumContent && <TopAlbumsThisMonth />}
{showAlbumContent && <NewAlbumReleases />}
{showAlbumContent && <BestSellingAlbums />}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<InViewWrapper>
<ExploreSection title={messages.trendingGenres}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
keyboardShouldPersistTaps='handled'
>
<Flex row gap='s' alignItems='center'>
{showSkeleton
? Array.from({ length: SKELETON_COUNT }).map((_, i) => (
<Skeleton
key={i}
h={36}
w={96}
borderRadius='2xl'
noShimmer
/>
))
: topGenres.map((genre) => (
<SelectablePill
key={genre.value}
type='button'
size='large'
label={genre.label}
onPress={() => handleGenrePress(genre.value)}
/>
))}
</Flex>
</ScrollView>
</ExploreSection>
</InViewWrapper>
)
}
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -47,8 +46,6 @@ const messages = {
selected: 'Selected'
}

const trendingGenres = [ALL_GENRES, ...GENRES]

const useStyles = makeStyles(({ palette, spacing, typography }) => ({
root: {
flex: 1
Expand Down Expand Up @@ -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 }))
Expand Down Expand Up @@ -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()
},
Expand Down Expand Up @@ -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 (
<Pressable onPress={() => handleSelect(genre)}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { usePopularGenres } from '@audius/common/api'
import { TimeRange } from '@audius/common/models'
import {
trendingPageActions,
trendingPageSelectors
} from '@audius/common/store'
import {
Genre,
TRENDING_GENRES,
toTrendingGenre,
getTrendingGenreSuggestions,
toTrendingGenreValue,
route
} from '@audius/common/utils'
import { connect } from 'react-redux'
Expand Down Expand Up @@ -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 (
<TrendingGenreSelectionPage
genres={TRENDING_GENRES}
genres={allGenres}
topGenres={topGenres}
didSelectGenre={setTrimmedGenre}
selectedGenre={genre}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type TrendingGenreSelectionPageProps = {
selectedGenre: string | null
didSelectGenre: (genre: string | null) => void
genres: string[]
topGenres?: string[]
}

const messages = {
Expand All @@ -19,7 +20,8 @@ const messages = {
const TrendingGenreSelectionPage = ({
selectedGenre,
didSelectGenre,
genres
genres,
topGenres
}: TrendingGenreSelectionPageProps) => {
const { setLeft, setCenter, setRight } = useContext(NavContext)!

Expand All @@ -33,6 +35,7 @@ const TrendingGenreSelectionPage = ({
<MobilePageContainer backgroundClassName={styles.pageBackground} fullHeight>
<GenreSelectionList
genres={genres}
topGenres={topGenres}
didSelectGenre={didSelectGenre}
selectedGenre={selectedGenre}
containerClassName={styles.container}
Expand Down
Loading
Loading