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
2 changes: 2 additions & 0 deletions packages/common/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ export * from './tan-query/users/useMutualFollowers'
export * from './tan-query/users/useMutedUsers'
export * from './tan-query/users/useRelatedArtists'
export * from './tan-query/users/useSuggestedArtists'
export * from './tan-query/users/useSuggestedFollows'
export * from './tan-query/users/useFollowSuggestions'
export * from './tan-query/users/useTopArtists'
export * from './tan-query/users/useTopArtistsInGenre'
export * from './tan-query/users/useUserAlbums'
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 @@ -52,6 +52,7 @@ export const QUERY_KEYS = {
search: 'search',
trending: 'trending',
suggestedArtists: 'suggestedArtists',
suggestedFollows: 'suggestedFollows',
topArtistsInGenre: 'topArtistsInGenre',
audioTransactions: 'audioTransactions',
audioTransactionsCount: 'audioTransactionsCount',
Expand Down
50 changes: 50 additions & 0 deletions packages/common/src/api/tan-query/users/useFollowSuggestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { QueryOptions } from '../types'

import { useSuggestedFollowsUsers } from './useSuggestedFollows'
import { useTopArtists } from './useTopArtists'

export type UseFollowSuggestionsArgs = {
limit?: number
}

/**
* Artists to suggest the user follow on empty-feed / "find artists" surfaces.
*
* Prefers suggestions personalized from the user's own favorites and reposts,
* and falls back to the curated featured list when there aren't any — which is
* the common case for a brand new account, and the only case for a signed-out
* one. Both surfaces that show follow suggestions share this so the fallback
* rule lives in one place.
*/
export const useFollowSuggestions = (
{ limit }: UseFollowSuggestionsArgs = {},
options?: QueryOptions
) => {
const { data: personalized, isPending: isPersonalizedPending } =
useSuggestedFollowsUsers({ limit }, options)

const hasPersonalized = !!personalized?.length

// Fetched unconditionally rather than gated on personalization coming back
// empty: the empty case is exactly the new-account case this surface exists
// for, and gating would put a serial request in front of it. The fallback is
// a small static list, so fetching it and discarding it costs ~nothing.
const { data: featured, isPending: isFeaturedPending } = useTopArtists(
'Featured',
options
)

if (isPersonalizedPending) {
return { data: undefined, isPending: true, isPersonalized: false }
}

if (hasPersonalized) {
return { data: personalized, isPending: false, isPersonalized: true }
}

return {
data: featured,
isPending: isFeaturedPending,
isPersonalized: false
}
}
68 changes: 68 additions & 0 deletions packages/common/src/api/tan-query/users/useSuggestedFollows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Id, OptionalId } from '@audius/sdk'
import { useQuery, useQueryClient } from '@tanstack/react-query'

import { userMetadataListFromSDK } from '~/adapters/user'
import { useQueryContext } from '~/api/tan-query/utils'
import { ID } from '~/models/Identifiers'

import { QUERY_KEYS } from '../queryKeys'
import { QueryKey, QueryOptions } from '../types'
import { primeUserData } from '../utils/primeUserData'

import { useCurrentUserId } from './account/useCurrentUserId'
import { useUsers } from './useUsers'

const DEFAULT_LIMIT = 10

export type UseSuggestedFollowsArgs = {
limit?: number
}

export const getSuggestedFollowsQueryKey = ({
userId,
limit = DEFAULT_LIMIT
}: UseSuggestedFollowsArgs & { userId: ID | null | undefined }) =>
[QUERY_KEYS.suggestedFollows, userId, { limit }] as unknown as QueryKey<ID[]>

/**
* Artists to suggest the current user follow, derived from the tracks and
* albums they have favorited or reposted but whose artist they don't already
* follow.
*
* Returns an empty array for users with no favorites or reposts — see
* `useFollowSuggestions` for the surface-level fallback to featured artists.
*/
export const useSuggestedFollows = (
{ limit = DEFAULT_LIMIT }: UseSuggestedFollowsArgs = {},
options?: QueryOptions
) => {
const { audiusSdk } = useQueryContext()
const { data: currentUserId } = useCurrentUserId()
const queryClient = useQueryClient()

return useQuery({
queryKey: getSuggestedFollowsQueryKey({ userId: currentUserId, limit }),
queryFn: async () => {
const sdk = await audiusSdk()
const { data = [] } = await sdk.users.getSuggestedFollows({
id: Id.parse(currentUserId),
limit,
userId: OptionalId.parse(currentUserId)
})
const users = userMetadataListFromSDK(data)
primeUserData({ users, queryClient })
return users.map((user) => user.user_id)
},
...options,
enabled: options?.enabled !== false && !!currentUserId
})
}

export const useSuggestedFollowsUsers = (
args: UseSuggestedFollowsArgs = {},
options?: QueryOptions
) => {
const { data: userIds, isPending } = useSuggestedFollows(args, options)
const { data: users } = useUsers(userIds)
return { data: users, isPending }
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback } from 'react'

import { useSuggestedArtists } from '@audius/common/api'
import { useFollowSuggestions } from '@audius/common/api'
import type { ID } from '@audius/common/models'
import {
removeFollowArtists,
Expand Down Expand Up @@ -28,7 +28,7 @@ export const SuggestedArtistsList = (props: SuggestedArtistsListProps) => {
const { secondaryLight2, secondaryDark2, white } = useThemeColors()
const dispatch = useDispatch()

const { data: suggestedArtists } = useSuggestedArtists()
const { data: suggestedArtists } = useFollowSuggestions()
const selectedArtistIds: ID[] = useSelector(getFollowIds)

const handleSelectArtist = useCallback(
Expand Down
71 changes: 70 additions & 1 deletion packages/sdk/src/sdk/api/users/UsersApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
DownloadSalesAsCSVRequest,
DownloadUSDCWithdrawalsAsCSVRequest,
UsersApi as GeneratedUsersApi,
RelatedArtistResponseFromJSON,
type RelatedArtistResponse,
type UserPlaylistLibrary
} from '../generated/default'
import * as runtime from '../generated/default/runtime'
Expand Down Expand Up @@ -55,7 +57,8 @@ import {
type CreateUserRequestWithFiles,
type UserFileUploadParams,
type EntityManagerPlaylistLibraryContents,
type UsersApiServicesConfig
type UsersApiServicesConfig,
type GetSuggestedFollowsRequest
} from './types'

export class UsersApi extends GeneratedUsersApi {
Expand Down Expand Up @@ -864,4 +867,70 @@ export class UsersApi extends GeneratedUsersApi {
})
})
}

/**
* Gets artists to suggest the user follow, based on the tracks and albums
* they have favorited or reposted but whose artist they do not already
* follow. Returns an empty list for users with no favorites or reposts.
*
* Hand-written for the same reason as `GetSuggestedFollowsRequest`: this
* endpoint is newer than the checked-in generated client. It mirrors what
* the generator would emit, so replacing it with the generated method later
* is a no-op for callers.
*/
async getSuggestedFollows(
params: GetSuggestedFollowsRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction
): Promise<RelatedArtistResponse> {
if (params.id === null || params.id === undefined) {
throw new runtime.RequiredError(
'id',
'Required parameter params.id was null or undefined when calling getSuggestedFollows.'
)
}

const queryParameters: any = {}

if (params.offset !== undefined) {
queryParameters.offset = params.offset
}

if (params.limit !== undefined) {
queryParameters.limit = params.limit
}

if (params.userId !== undefined) {
queryParameters.user_id = params.userId
}

const headerParameters: runtime.HTTPHeaders = {}

if (
!headerParameters.Authorization &&
this.configuration &&
this.configuration.accessToken
) {
const token = await this.configuration.accessToken('OAuth2', ['read'])
if (token) {
headerParameters.Authorization = token
}
}

const response = await this.request(
{
path: `/users/{id}/suggested-follows`.replace(
`{${'id'}}`,
encodeURIComponent(String(params.id))
),
method: 'GET',
headers: headerParameters,
query: queryParameters
},
initOverrides
)

return await new runtime.JSONApiResponse(response, (jsonValue) =>
RelatedArtistResponseFromJSON(jsonValue)
).value()
}
}
18 changes: 18 additions & 0 deletions packages/sdk/src/sdk/api/users/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,3 +321,21 @@ export type CreateUserRequestWithFiles = CreateUserRequest &

export type UpdateUserRequestWithFiles = UpdateUserRequest &
UserFileUploadParams

/**
* Params for `UsersApi.getSuggestedFollows`.
*
* Hand-written rather than generated: the endpoint post-dates the last SDK
* regeneration. Delete this and use the generated request type once
* `npm run gen` has been re-run against a node serving /users/{id}/suggested-follows.
*/
export type GetSuggestedFollowsRequest = {
/** A User ID */
id: string
/** The number of items to fetch */
limit?: number
/** The number of items to skip. Useful for pagination (page number * limit) */
offset?: number
/** The user ID of the user making the request */
userId?: string
}
11 changes: 7 additions & 4 deletions packages/web/src/pages/feed-page/components/FollowUsers.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback } from 'react'

import { useTopArtists } from '@audius/common/api'
import { useFollowSuggestions } from '@audius/common/api'
import { FollowSource } from '@audius/common/models'
import { usersSocialActions } from '@audius/common/store'
import { Button, Flex, IconUserFollow, Text } from '@audius/harmony'
Expand All @@ -12,6 +12,7 @@ import { SelectArtistsPreviewContextProvider } from 'components/follow-artist-ca

const messages = {
cta: `Let’s fix that by following some of these artists!`,
personalizedCta: `Here are artists you’ve liked but aren’t following yet.`,
noFollowers: `Your feed is empty`
}

Expand All @@ -26,7 +27,7 @@ const initialValues: FollowUsersValues = {
const FollowUsers = () => {
const dispatch = useDispatch()

const { data: featuredArtists } = useTopArtists('Featured')
const { data: suggestedArtists, isPersonalized } = useFollowSuggestions()

const handleSubmit = useCallback(
(values: FollowUsersValues) => {
Expand All @@ -50,7 +51,9 @@ const FollowUsers = () => {
{messages.noFollowers}{' '}
<i className='emoji face-screaming-in-fear' />
</Text>
<Text variant='body'>{messages.cta}</Text>
<Text variant='body'>
{isPersonalized ? messages.personalizedCta : messages.cta}
</Text>
</Flex>
<Flex
inline
Expand All @@ -60,7 +63,7 @@ const FollowUsers = () => {
gap='m'
m='s'
>
{featuredArtists?.map((artist) => (
{suggestedArtists?.map((artist) => (
<FollowArtistCard key={artist.user_id} user={artist} />
))}
</Flex>
Expand Down
Loading