diff --git a/.changeset/listener-cities.md b/.changeset/listener-cities.md new file mode 100644 index 00000000000..69b19838603 --- /dev/null +++ b/.changeset/listener-cities.md @@ -0,0 +1,5 @@ +--- +'@audius/sdk': minor +--- + +Add `users.getListenerCities` for an artist's top listener cities diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index e326c719548..e59c3d4bb13 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -145,6 +145,7 @@ export * from './tan-query/users/useUsers' export * from './tan-query/users/useUser' export * from './tan-query/users/useUserTracksByHandle' export * from './tan-query/users/useUserTrackDownloadCountTotal' +export * from './tan-query/users/useListenerCities' export * from './tan-query/users/useProfileUser' export * from './tan-query/users/useOtherChatUsers' diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index e68fe86e4f5..a034f92ad47 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -31,6 +31,7 @@ export const QUERY_KEYS = { userPlaylists: 'userPlaylists', userAlbums: 'userAlbums', userTracksDownloadCount: 'userTracksDownloadCount', + listenerCities: 'listenerCities', collection: 'collection', collections: 'collections', collectionByPermalink: 'collectionByPermalink', diff --git a/packages/common/src/api/tan-query/users/useListenerCities.ts b/packages/common/src/api/tan-query/users/useListenerCities.ts new file mode 100644 index 00000000000..ab688685d08 --- /dev/null +++ b/packages/common/src/api/tan-query/users/useListenerCities.ts @@ -0,0 +1,51 @@ +import { Id, ListenerCity } from '@audius/sdk' +import { useQuery } from '@tanstack/react-query' + +import { useQueryContext } from '~/api/tan-query/utils' +import { ID } from '~/models' + +import { QUERY_KEYS } from '../queryKeys' +import { QueryKey, QueryOptions } from '../types' + +export type ListenerCitiesTime = 'week' | 'month' | 'all' + +type UseListenerCitiesArgs = { + userId: ID | null | undefined + time: ListenerCitiesTime + limit?: number +} + +export const getListenerCitiesQueryKey = ({ + userId, + time, + limit +}: UseListenerCitiesArgs) => + [QUERY_KEYS.listenerCities, userId, { time, limit }] as unknown as QueryKey< + ListenerCity[] + > + +/** + * Cities whose listeners played the artist's tracks the most. Only the + * artist or one of their managers can read this. + */ +export const useListenerCities = ( + { userId, time, limit = 10 }: UseListenerCitiesArgs, + options?: QueryOptions +) => { + const { audiusSdk } = useQueryContext() + + return useQuery({ + queryKey: getListenerCitiesQueryKey({ userId, time, limit }), + queryFn: async () => { + const sdk = await audiusSdk() + const { data = [] } = await sdk.users.getListenerCities({ + id: Id.parse(userId), + time, + limit + }) + return data + }, + ...options, + enabled: options?.enabled !== false && !!userId + }) +} diff --git a/packages/common/src/services/remote-config/feature-flags.ts b/packages/common/src/services/remote-config/feature-flags.ts index 52138d0a3f3..ab7dde5c243 100644 --- a/packages/common/src/services/remote-config/feature-flags.ts +++ b/packages/common/src/services/remote-config/feature-flags.ts @@ -25,7 +25,8 @@ export enum FeatureFlags { BULK_TRACK_EDIT = 'bulk_track_edit', WEEKLY_ARTIST_RECAP = 'weekly_artist_recap', TRACK_FEEDBACK_REQUESTS = 'track_feedback_requests', - GROUP_MESSAGES = 'group_messages' + GROUP_MESSAGES = 'group_messages', + DASHBOARD_TOP_CITIES = 'dashboard_top_cities' } type FlagDefaults = Record @@ -42,7 +43,8 @@ export const environmentFlagDefaults: Record< [FeatureFlags.BULK_TRACK_EDIT]: true, [FeatureFlags.WEEKLY_ARTIST_RECAP]: true, [FeatureFlags.TRACK_FEEDBACK_REQUESTS]: true, - [FeatureFlags.GROUP_MESSAGES]: true + [FeatureFlags.GROUP_MESSAGES]: true, + [FeatureFlags.DASHBOARD_TOP_CITIES]: true }, production: {} } @@ -89,5 +91,7 @@ export const flagDefaults: FlagDefaults = { // "Looking for feedback" toggle, badge, and Explore section. [FeatureFlags.TRACK_FEEDBACK_REQUESTS]: false, // Creating group DMs. Existing groups always render. - [FeatureFlags.GROUP_MESSAGES]: false + [FeatureFlags.GROUP_MESSAGES]: false, + // Top cities card on the web Artist Dashboard. + [FeatureFlags.DASHBOARD_TOP_CITIES]: false } diff --git a/packages/sdk/src/sdk/api/generated/default/.openapi-generator/FILES b/packages/sdk/src/sdk/api/generated/default/.openapi-generator/FILES index 2c8c24f19b5..305535e0323 100644 --- a/packages/sdk/src/sdk/api/generated/default/.openapi-generator/FILES +++ b/packages/sdk/src/sdk/api/generated/default/.openapi-generator/FILES @@ -204,6 +204,8 @@ models/ListenCount.ts models/ListenStreakReminderNotification.ts models/ListenStreakReminderNotificationAction.ts models/ListenStreakReminderNotificationActionData.ts +models/ListenerCitiesResponse.ts +models/ListenerCity.ts models/ManagedUser.ts models/ManagedUsersResponse.ts models/ManagersResponse.ts diff --git a/packages/sdk/src/sdk/api/generated/default/apis/UsersApi.ts b/packages/sdk/src/sdk/api/generated/default/apis/UsersApi.ts index 931c3652767..1ee8ee8f92f 100644 --- a/packages/sdk/src/sdk/api/generated/default/apis/UsersApi.ts +++ b/packages/sdk/src/sdk/api/generated/default/apis/UsersApi.ts @@ -37,6 +37,7 @@ import type { GetSupporters, GetSupporting, HistoryResponse, + ListenerCitiesResponse, ManagedUsersResponse, ManagersResponse, MutualFollowersResponse, @@ -122,6 +123,8 @@ import { GetSupportingToJSON, HistoryResponseFromJSON, HistoryResponseToJSON, + ListenerCitiesResponseFromJSON, + ListenerCitiesResponseToJSON, ManagedUsersResponseFromJSON, ManagedUsersResponseToJSON, ManagersResponseFromJSON, @@ -348,6 +351,15 @@ export interface GetGranteeUsersRequest { userId?: string; } +export interface GetListenerCitiesRequest { + id: string; + time?: GetListenerCitiesTimeEnum; + limit?: number; + userId?: string; + encodedDataMessage?: string; + encodedDataSignature?: string; +} + export interface GetManagedUsersRequest { id: string; isApproved?: boolean; @@ -1931,6 +1943,64 @@ export class UsersApi extends runtime.BaseAPI { return await response.value(); } + /** + * @hidden + * Gets the cities whose listeners played the user\'s tracks the most. Plays by the user and plays with no known city are not counted. Locations come from the listener\'s IP address and can be wrong for VPN and mobile carrier traffic. Only the user or one of their managers can call this. + */ + async getListenerCitiesRaw(params: GetListenerCitiesRequest, 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 getListenerCities.'); + } + + const queryParameters: any = {}; + + if (params.time !== undefined) { + queryParameters['time'] = params.time; + } + + if (params.limit !== undefined) { + queryParameters['limit'] = params.limit; + } + + if (params.userId !== undefined) { + queryParameters['user_id'] = params.userId; + } + + const headerParameters: runtime.HTTPHeaders = {}; + + if (params.encodedDataMessage !== undefined && params.encodedDataMessage !== null) { + headerParameters['Encoded-Data-Message'] = String(params.encodedDataMessage); + } + + if (params.encodedDataSignature !== undefined && params.encodedDataSignature !== null) { + headerParameters['Encoded-Data-Signature'] = String(params.encodedDataSignature); + } + + 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}/listener_cities`.replace(`{${"id"}}`, encodeURIComponent(String(params.id))), + method: 'GET', + headers: headerParameters, + query: queryParameters, + }, initOverrides); + + return new runtime.JSONApiResponse(response, (jsonValue) => ListenerCitiesResponseFromJSON(jsonValue)); + } + + /** + * Gets the cities whose listeners played the user\'s tracks the most. Plays by the user and plays with no known city are not counted. Locations come from the listener\'s IP address and can be wrong for VPN and mobile carrier traffic. Only the user or one of their managers can call this. + */ + async getListenerCities(params: GetListenerCitiesRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise { + const response = await this.getListenerCitiesRaw(params, initOverrides); + return await response.value(); + } + /** * @hidden * Gets a list of users managed by the given user @@ -5373,6 +5443,15 @@ export const GetContestsByUserStatusEnum = { All: 'all' } as const; export type GetContestsByUserStatusEnum = typeof GetContestsByUserStatusEnum[keyof typeof GetContestsByUserStatusEnum]; +/** + * @export + */ +export const GetListenerCitiesTimeEnum = { + Week: 'week', + Month: 'month', + All: 'all' +} as const; +export type GetListenerCitiesTimeEnum = typeof GetListenerCitiesTimeEnum[keyof typeof GetListenerCitiesTimeEnum]; /** * @export */ diff --git a/packages/sdk/src/sdk/api/generated/default/models/ListenerCitiesResponse.ts b/packages/sdk/src/sdk/api/generated/default/models/ListenerCitiesResponse.ts new file mode 100644 index 00000000000..5426a0a2e55 --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/default/models/ListenerCitiesResponse.ts @@ -0,0 +1,72 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * Audius API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { ListenerCity } from './ListenerCity'; +import { + ListenerCityFromJSON, + ListenerCityFromJSONTyped, + ListenerCityToJSON, +} from './ListenerCity'; + +/** + * + * @export + * @interface ListenerCitiesResponse + */ +export interface ListenerCitiesResponse { + /** + * + * @type {Array} + * @memberof ListenerCitiesResponse + */ + data?: Array; +} + +/** + * Check if a given object implements the ListenerCitiesResponse interface. + */ +export function instanceOfListenerCitiesResponse(value: object): value is ListenerCitiesResponse { + let isInstance = true; + + return isInstance; +} + +export function ListenerCitiesResponseFromJSON(json: any): ListenerCitiesResponse { + return ListenerCitiesResponseFromJSONTyped(json, false); +} + +export function ListenerCitiesResponseFromJSONTyped(json: any, ignoreDiscriminator: boolean): ListenerCitiesResponse { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'data': !exists(json, 'data') ? undefined : ((json['data'] as Array).map(ListenerCityFromJSON)), + }; +} + +export function ListenerCitiesResponseToJSON(value?: ListenerCitiesResponse | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'data': value.data === undefined ? undefined : ((value.data as Array).map(ListenerCityToJSON)), + }; +} + diff --git a/packages/sdk/src/sdk/api/generated/default/models/ListenerCity.ts b/packages/sdk/src/sdk/api/generated/default/models/ListenerCity.ts new file mode 100644 index 00000000000..05804f4aeaf --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/default/models/ListenerCity.ts @@ -0,0 +1,93 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * Audius API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +/** + * + * @export + * @interface ListenerCity + */ +export interface ListenerCity { + /** + * + * @type {string} + * @memberof ListenerCity + */ + city: string; + /** + * Region or state name, empty when unknown + * @type {string} + * @memberof ListenerCity + */ + region: string; + /** + * Country name, empty when unknown + * @type {string} + * @memberof ListenerCity + */ + country: string; + /** + * + * @type {number} + * @memberof ListenerCity + */ + playCount: number; +} + +/** + * Check if a given object implements the ListenerCity interface. + */ +export function instanceOfListenerCity(value: object): value is ListenerCity { + let isInstance = true; + isInstance = isInstance && "city" in value && value["city"] !== undefined; + isInstance = isInstance && "region" in value && value["region"] !== undefined; + isInstance = isInstance && "country" in value && value["country"] !== undefined; + isInstance = isInstance && "playCount" in value && value["playCount"] !== undefined; + + return isInstance; +} + +export function ListenerCityFromJSON(json: any): ListenerCity { + return ListenerCityFromJSONTyped(json, false); +} + +export function ListenerCityFromJSONTyped(json: any, ignoreDiscriminator: boolean): ListenerCity { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'city': json['city'], + 'region': json['region'], + 'country': json['country'], + 'playCount': json['play_count'], + }; +} + +export function ListenerCityToJSON(value?: ListenerCity | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'city': value.city, + 'region': value.region, + 'country': value.country, + 'play_count': value.playCount, + }; +} + diff --git a/packages/sdk/src/sdk/api/generated/default/models/index.ts b/packages/sdk/src/sdk/api/generated/default/models/index.ts index cb8cbe45dbe..7410b2090cd 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/index.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/index.ts @@ -181,6 +181,8 @@ export * from './ListenCount'; export * from './ListenStreakReminderNotification'; export * from './ListenStreakReminderNotificationAction'; export * from './ListenStreakReminderNotificationActionData'; +export * from './ListenerCitiesResponse'; +export * from './ListenerCity'; export * from './ManagedUser'; export * from './ManagedUsersResponse'; export * from './ManagersResponse'; diff --git a/packages/web/src/pages/dashboard-page/DashboardPage.tsx b/packages/web/src/pages/dashboard-page/DashboardPage.tsx index 249adb2b845..9e666d7dcf1 100644 --- a/packages/web/src/pages/dashboard-page/DashboardPage.tsx +++ b/packages/web/src/pages/dashboard-page/DashboardPage.tsx @@ -12,6 +12,8 @@ import { useCurrentAccountUser, useUserTrackDownloadCountTotal } from '@audius/common/api' +import { useFeatureFlag } from '@audius/common/hooks' +import { FeatureFlags } from '@audius/common/services' import { themeSelectors } from '@audius/common/store' import { dayjs, Dayjs, formatCount } from '@audius/common/utils' import { encodeHashId } from '@audius/sdk' @@ -27,6 +29,7 @@ import lazyWithPreload from 'utils/lazyWithPreload' import styles from './DashboardPage.module.css' import { ArtistCard } from './components/ArtistCard' import { ArtistContentSection } from './components/ArtistContentSection' +import { TopCitiesCard } from './components/TopCitiesCard' import { useFormattedTrackData } from './components/hooks' const { getTheme } = themeSelectors @@ -122,6 +125,9 @@ export const DashboardPage = () => { const { data: listenData, isPending: listenDataPending } = useArtistDashboardListenData(listenWindow) const theme = useSelector(getTheme) + const { isEnabled: isTopCitiesEnabled } = useFeatureFlag( + FeatureFlags.DASHBOARD_TOP_CITIES + ) const header =
@@ -206,6 +212,9 @@ export const DashboardPage = () => {
{renderChart()} {renderStats()} + {isTopCitiesEnabled && account.track_count > 0 ? ( + + ) : null}
diff --git a/packages/web/src/pages/dashboard-page/components/TopCitiesCard.tsx b/packages/web/src/pages/dashboard-page/components/TopCitiesCard.tsx new file mode 100644 index 00000000000..ef962b996da --- /dev/null +++ b/packages/web/src/pages/dashboard-page/components/TopCitiesCard.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react' + +import { ListenerCitiesTime, useListenerCities } from '@audius/common/api' +import { ID } from '@audius/common/models' +import { formatCount } from '@audius/common/utils' +import { + Box, + Flex, + Paper, + SegmentedControl, + Skeleton, + Text +} from '@audius/harmony' + +const messages = { + title: 'Top Cities', + description: + 'Where your listeners are, based on their internet connection. VPNs and mobile networks can show a nearby city instead.', + week: 'Last 7 Days', + month: 'Last 30 Days', + all: 'All Time', + empty: 'No listener locations for this period yet.', + error: 'Couldn’t load listener locations.', + plays: (count: number) => + `${formatCount(count)} ${count === 1 ? 'play' : 'plays'}` +} + +const timeOptions: { key: ListenerCitiesTime; text: string }[] = [ + { key: 'week', text: messages.week }, + { key: 'month', text: messages.month }, + { key: 'all', text: messages.all } +] + +const LIMIT = 10 + +const formatPlace = (region: string, country: string) => + [region, country].filter(Boolean).join(', ') + +type TopCitiesCardProps = { + userId: ID +} + +export const TopCitiesCard = ({ userId }: TopCitiesCardProps) => { + const [time, setTime] = useState('month') + const { + data: cities, + isPending, + isError + } = useListenerCities({ userId, time, limit: LIMIT }) + const maxCount = cities?.[0]?.playCount ?? 0 + + return ( + + + + + {messages.title} + + + {messages.description} + + + + + {isPending ? ( + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + ) : isError ? ( + + {messages.error} + + ) : !cities?.length ? ( + + {messages.empty} + + ) : ( + + {cities.map((city, i) => ( + + + {i + 1} + + + + {city.city} + + + {formatPlace(city.region, city.country)} + + + + ({ + width: `${Math.max(2, (city.playCount / maxCount) * 100)}%`, + backgroundColor: theme.color.background.primary + })} + /> + + + {messages.plays(city.playCount)} + + + ))} + + )} + + ) +}