From 22163a7e670ac9921c29d45bcc901800f6deae96 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Tue, 18 Aug 2026 14:52:09 -0700 Subject: [PATCH] feat(client): personalize empty-feed follow suggestions Wires the new GET /users/:id/suggested-follows endpoint into the two surfaces that currently show a hardcoded list: web's empty feed and mobile's SuggestedFollows. useFollowSuggestions is the shared entry point -- personalized suggestions when the user has favorites or reposts to draw on, and the existing SUGGESTED_FOLLOW_HANDLES list when they don't. Both surfaces go through it so the fallback rule lives in one place rather than being duplicated per platform. Sign-up artist selection deliberately keeps the static list; personalization has nothing to work with there. The fallback is fetched unconditionally rather than gated on the personalized query coming back empty. Empty is exactly the new-account case these surfaces exist for, so gating would put a serial round-trip in front of the users who need them most, and the fallback is a small static file. sdk.users.getSuggestedFollows is hand-written rather than generated: `npm run gen` pulls the spec from a running node, so the generated method can't exist until the API side deploys. It mirrors what the generator emits, and both it and its request type carry a comment to delete them after the next regen. Co-Authored-By: Claude Opus 5 --- packages/common/src/api/index.ts | 2 + .../common/src/api/tan-query/queryKeys.ts | 1 + .../tan-query/users/useFollowSuggestions.ts | 50 +++++++++++++ .../tan-query/users/useSuggestedFollows.ts | 68 ++++++++++++++++++ .../SuggestedArtistsList.tsx | 4 +- packages/sdk/src/sdk/api/users/UsersApi.ts | 71 ++++++++++++++++++- packages/sdk/src/sdk/api/users/types.ts | 18 +++++ .../feed-page/components/FollowUsers.tsx | 11 +-- 8 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 packages/common/src/api/tan-query/users/useFollowSuggestions.ts create mode 100644 packages/common/src/api/tan-query/users/useSuggestedFollows.ts diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index f604a4daebf..431c75327b8 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -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' diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index 2fd96d37362..4e3715d502c 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -52,6 +52,7 @@ export const QUERY_KEYS = { search: 'search', trending: 'trending', suggestedArtists: 'suggestedArtists', + suggestedFollows: 'suggestedFollows', topArtistsInGenre: 'topArtistsInGenre', audioTransactions: 'audioTransactions', audioTransactionsCount: 'audioTransactionsCount', diff --git a/packages/common/src/api/tan-query/users/useFollowSuggestions.ts b/packages/common/src/api/tan-query/users/useFollowSuggestions.ts new file mode 100644 index 00000000000..f78bbcec6cc --- /dev/null +++ b/packages/common/src/api/tan-query/users/useFollowSuggestions.ts @@ -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 + } +} diff --git a/packages/common/src/api/tan-query/users/useSuggestedFollows.ts b/packages/common/src/api/tan-query/users/useSuggestedFollows.ts new file mode 100644 index 00000000000..a823ca4fca4 --- /dev/null +++ b/packages/common/src/api/tan-query/users/useSuggestedFollows.ts @@ -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 + +/** + * 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 } +} diff --git a/packages/mobile/src/components/suggested-follows/SuggestedArtistsList.tsx b/packages/mobile/src/components/suggested-follows/SuggestedArtistsList.tsx index 28101e72029..5b2a24fa301 100644 --- a/packages/mobile/src/components/suggested-follows/SuggestedArtistsList.tsx +++ b/packages/mobile/src/components/suggested-follows/SuggestedArtistsList.tsx @@ -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, @@ -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( diff --git a/packages/sdk/src/sdk/api/users/UsersApi.ts b/packages/sdk/src/sdk/api/users/UsersApi.ts index 74bfbf57c31..dc04a39c904 100644 --- a/packages/sdk/src/sdk/api/users/UsersApi.ts +++ b/packages/sdk/src/sdk/api/users/UsersApi.ts @@ -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' @@ -55,7 +57,8 @@ import { type CreateUserRequestWithFiles, type UserFileUploadParams, type EntityManagerPlaylistLibraryContents, - type UsersApiServicesConfig + type UsersApiServicesConfig, + type GetSuggestedFollowsRequest } from './types' export class UsersApi extends GeneratedUsersApi { @@ -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 { + 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() + } } diff --git a/packages/sdk/src/sdk/api/users/types.ts b/packages/sdk/src/sdk/api/users/types.ts index 77ec5bf6212..457694c7414 100644 --- a/packages/sdk/src/sdk/api/users/types.ts +++ b/packages/sdk/src/sdk/api/users/types.ts @@ -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 +} diff --git a/packages/web/src/pages/feed-page/components/FollowUsers.tsx b/packages/web/src/pages/feed-page/components/FollowUsers.tsx index e7902fdb0f3..802527d4ccc 100644 --- a/packages/web/src/pages/feed-page/components/FollowUsers.tsx +++ b/packages/web/src/pages/feed-page/components/FollowUsers.tsx @@ -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' @@ -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` } @@ -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) => { @@ -50,7 +51,9 @@ const FollowUsers = () => { {messages.noFollowers}{' '} - {messages.cta} + + {isPersonalized ? messages.personalizedCta : messages.cta} + { gap='m' m='s' > - {featuredArtists?.map((artist) => ( + {suggestedArtists?.map((artist) => ( ))}