diff --git a/packages/common/src/api/account.ts b/packages/common/src/api/account.ts index 7cb5e092795..3876712b337 100644 --- a/packages/common/src/api/account.ts +++ b/packages/common/src/api/account.ts @@ -1,4 +1,12 @@ import { createApi } from '~/audius-query' +import { + ID, + UserMetadata, + managedUserListFromSDK, + userManagerListFromSDK +} from '~/models' + +import { Id } from './utils' type ResetPasswordArgs = { email: string @@ -18,6 +26,21 @@ const accountApi = createApi({ type: 'query' } }, + getCurrentWeb3User: { + async fetch(_, { audiusBackend }) { + const libs = await audiusBackend.getAudiusLibsTyped() + // TODO: https://linear.app/audius/issue/PAY-2838/separate-walletentropy-user-and-current-user-in-state + // What happens in the cache if something here is null? + + // Note: This cast is mostly safe, but is missing info populated in AudiusBackend.getAccount() + // Okay for now as that info isn't generally available on non-account users and isn't used in manager mode. + return libs.Account?.getWeb3User() as UserMetadata | null + }, + options: { + type: 'query', + schemaKey: 'currentWeb3User' + } + }, resetPassword: { async fetch(args: ResetPasswordArgs, context) { const { email, password } = args @@ -29,10 +52,45 @@ const accountApi = createApi({ options: { type: 'mutation' } + }, + getManagedAccounts: { + async fetch({ userId }: { userId: ID }, { audiusSdk }) { + const sdk = await audiusSdk() + const managedUsers = await sdk.full.users.getManagedUsers({ + id: Id.parse(userId) + }) + + const { data = [] } = managedUsers + return managedUserListFromSDK(data) + }, + options: { + type: 'query', + schemaKey: 'managedUsers' + } + }, + getManagers: { + async fetch({ userId }: { userId: ID }, { audiusSdk }) { + const sdk = await audiusSdk() + const managedUsers = await sdk.full.users.getManagers({ + id: Id.parse(userId) + }) + + const { data = [] } = managedUsers + return userManagerListFromSDK(data) + }, + options: { + type: 'query', + schemaKey: 'userManagers' + } } } }) -export const { useGetCurrentUserId, useResetPassword } = accountApi.hooks +export const { + useGetCurrentUserId, + useGetCurrentWeb3User, + useResetPassword, + useGetManagedAccounts +} = accountApi.hooks export const accountApiReducer = accountApi.reducer diff --git a/packages/common/src/audius-query/schema.ts b/packages/common/src/audius-query/schema.ts index 767000cd8e9..09c71942185 100644 --- a/packages/common/src/audius-query/schema.ts +++ b/packages/common/src/audius-query/schema.ts @@ -22,8 +22,19 @@ export const collectionSchema = new schema.Entity( { idAttribute: 'playlist_id' } ) +export const managedUserSchema = new schema.Object({ + user: userSchema +}) + +export const userManagerSchema = new schema.Object({ + manager: userSchema +}) + export const apiResponseSchema = new schema.Object({ + currentWeb3User: userSchema, + managedUsers: new schema.Array(managedUserSchema), user: userSchema, + userManagers: new schema.Array(userManagerSchema), track: trackSchema, collection: collectionSchema, users: new schema.Array(userSchema), diff --git a/packages/common/src/hooks/index.ts b/packages/common/src/hooks/index.ts index 845523bdc56..f76de3cb310 100644 --- a/packages/common/src/hooks/index.ts +++ b/packages/common/src/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './useAccountSwitcher' export * from './useBooleanOnce' export * from './useFeatureFlag' export * from './useRemoteVar' diff --git a/packages/common/src/hooks/useAccountSwitcher.ts b/packages/common/src/hooks/useAccountSwitcher.ts new file mode 100644 index 00000000000..b547e7fd63e --- /dev/null +++ b/packages/common/src/hooks/useAccountSwitcher.ts @@ -0,0 +1,28 @@ +import { useCallback } from 'react' + +import { useAppContext } from '~/context' +import { UserMetadata } from '~/models/User' + +// Matches corresponding key in libs (DISCOVERY_PROVIDER_USER_WALLET_OVERRIDE) +const USER_WALLET_OVERRIDE_KEY = '@audius/user-wallet-override' + +export const useAccountSwitcher = () => { + const { localStorage } = useAppContext() + const switchAccount = useCallback( + async (user: UserMetadata) => { + if (!user.wallet) { + console.error('User has no wallet address') + return + } + + await localStorage.setItem(USER_WALLET_OVERRIDE_KEY, user.wallet) + await localStorage.clearAudiusAccount() + await localStorage.clearAudiusAccountUser() + + window.location.reload() + }, + [localStorage] + ) + + return { switchAccount } +} diff --git a/packages/common/src/models/Grant.ts b/packages/common/src/models/Grant.ts new file mode 100644 index 00000000000..655735a83fd --- /dev/null +++ b/packages/common/src/models/Grant.ts @@ -0,0 +1,25 @@ +import { full } from '@audius/sdk' + +import { Nullable, decodeHashId } from '~/utils' + +import { ID } from './Identifiers' + +export type Grant = { + grantee_address: string + user_id: Nullable + is_revoked: boolean + is_approved: boolean + created_at: string + updated_at: string +} + +export const grantFromSDK = (input: full.Grant): Grant => { + return { + grantee_address: input.granteeAddress, + user_id: decodeHashId(input.userId) ?? null, + is_revoked: input.isRevoked, + is_approved: input.isApproved, + created_at: input.createdAt, + updated_at: input.updatedAt + } +} diff --git a/packages/common/src/models/User.ts b/packages/common/src/models/User.ts index d45a46c2027..17d32d3a06e 100644 --- a/packages/common/src/models/User.ts +++ b/packages/common/src/models/User.ts @@ -21,6 +21,7 @@ import { SolanaWalletAddress, StringWei, WalletAddress } from '~/models/Wallet' import { decodeHashId } from '~/utils/hashIds' import { Nullable, removeNullable } from '~/utils/typeUtils' +import { Grant, grantFromSDK } from './Grant' import { Timestamped } from './Timestamped' import { UserEvent } from './UserEvent' @@ -85,6 +86,16 @@ export type UserMetadata = { events?: UserEvent } & Timestamped +export type ManagedUserMetadata = { + grant: Grant + user: UserMetadata +} + +export type UserManagerMetadata = { + grant: Grant + manager: UserMetadata +} + export type ComputedUserProperties = { _profile_picture_sizes: ProfilePictureSizes _cover_photo_sizes: CoverPhotoSizes @@ -164,3 +175,35 @@ export const userMetadataFromSDK = ( export const userMetadataListFromSDK = (input?: full.UserFull[]) => input ? input.map((d) => userMetadataFromSDK(d)).filter(removeNullable) : [] + +export const managedUserFromSDK = ( + input: full.ManagedUser +): ManagedUserMetadata | undefined => { + const user = userMetadataFromSDK(input.user) + if (!user) { + return undefined + } + return { + user, + grant: grantFromSDK(input.grant) + } +} + +export const managedUserListFromSDK = (input?: full.ManagedUser[]) => + input ? input.map((d) => managedUserFromSDK(d)).filter(removeNullable) : [] + +export const userManagerFromSDK = ( + input: full.UserManager +): UserManagerMetadata | undefined => { + const manager = userMetadataFromSDK(input.manager) + if (!manager) { + return undefined + } + return { + manager, + grant: grantFromSDK(input.grant) + } +} + +export const userManagerListFromSDK = (input?: full.UserManager[]) => + input ? input.map((d) => userManagerFromSDK(d)).filter(removeNullable) : [] diff --git a/packages/common/src/services/audius-backend/AudiusBackend.ts b/packages/common/src/services/audius-backend/AudiusBackend.ts index c29f786f9f3..4bc5752afd9 100644 --- a/packages/common/src/services/audius-backend/AudiusBackend.ts +++ b/packages/common/src/services/audius-backend/AudiusBackend.ts @@ -706,7 +706,7 @@ export const audiusBackend = ({ getRemoteVar(IntKeys.DISCOVERY_NODE_MAX_BLOCK_DIFF) ?? undefined, discoveryNodeSelector, - enableUserIdOverride: isManagerModeEnabled + enableUserWalletOverride: isManagerModeEnabled }, identityServiceConfig: AudiusLibs.configIdentityService(identityServiceUrl), diff --git a/packages/libs/src/api/Account.ts b/packages/libs/src/api/Account.ts index d82175944e1..c6ea9d6f4f5 100644 --- a/packages/libs/src/api/Account.ts +++ b/packages/libs/src/api/Account.ts @@ -25,6 +25,7 @@ export class Account extends Base { this.ServiceProvider = serviceProvider this.getCurrentUser = this.getCurrentUser.bind(this) + this.getWeb3User = this.getWeb3User.bind(this) this.login = this.login.bind(this) this.logout = this.logout.bind(this) this.generateRecoveryLink = this.generateRecoveryLink.bind(this) @@ -54,6 +55,15 @@ export class Account extends Base { return this.userStateManager.getCurrentUser() } + /** + * Fetches the user metadata for user belonging to the current web3 wallet. + * May be different if acting as a managed account. + * @return {Object} user metadata + */ + getWeb3User() { + return this.userStateManager.getWeb3User() + } + /** * Logs a user into Audius */ diff --git a/packages/libs/src/services/discoveryProvider/DiscoveryProvider.ts b/packages/libs/src/services/discoveryProvider/DiscoveryProvider.ts index 4842f56f76a..60dcd4ff7ba 100644 --- a/packages/libs/src/services/discoveryProvider/DiscoveryProvider.ts +++ b/packages/libs/src/services/discoveryProvider/DiscoveryProvider.ts @@ -21,7 +21,11 @@ import { DiscoveryProviderSelection, DiscoveryProviderSelectionConfig } from './DiscoveryProviderSelection' -import { DEFAULT_UNHEALTHY_BLOCK_DIFF, REQUEST_TIMEOUT_MS } from './constants' +import { + DEFAULT_UNHEALTHY_BLOCK_DIFF, + DISCOVERY_PROVIDER_USER_WALLET_OVERRIDE, + REQUEST_TIMEOUT_MS +} from './constants' import * as Requests from './requests' const MAX_MAKE_REQUEST_RETRY_COUNT = 5 @@ -65,7 +69,7 @@ export type DiscoveryProviderConfig = { unhealthySlotDiffPlays?: number unhealthyBlockDiff?: number discoveryNodeSelector?: DiscoveryNodeSelector - enableUserIdOverride?: boolean + enableUserWalletOverride?: boolean } & Pick< DiscoveryProviderSelectionConfig, 'selectionCallback' | 'monitoringCallbacks' | 'localStorage' @@ -97,10 +101,10 @@ type DiscoveryNodeChallenge = { disbursed_amount: number } -const getUserIdOverride = async (localStorage?: LocalStorage) => { +const getUserWalletOverride = async (localStorage?: LocalStorage) => { try { const userIdOverride = await localStorage?.getItem( - '@audius/user-id-override' + DISCOVERY_PROVIDER_USER_WALLET_OVERRIDE ) return userIdOverride == null ? undefined : userIdOverride } catch { @@ -152,7 +156,7 @@ export class DiscoveryProvider { | DiscoveryProviderSelection['monitoringCallbacks'] | undefined - enableUserIdOverride = false + enableUserWalletOverride = false discoveryProviderEndpoint: string | undefined isInitialized = false @@ -176,7 +180,7 @@ export class DiscoveryProvider { unhealthySlotDiffPlays, unhealthyBlockDiff, discoveryNodeSelector, - enableUserIdOverride = false + enableUserWalletOverride = false }: DiscoveryProviderConfig) { this.whitelist = whitelist this.blacklist = blacklist @@ -185,7 +189,7 @@ export class DiscoveryProvider { this.web3Manager = web3Manager this.selectionCallback = selectionCallback this.localStorage = localStorage - this.enableUserIdOverride = enableUserIdOverride + this.enableUserWalletOverride = enableUserWalletOverride this.unhealthyBlockDiff = unhealthyBlockDiff ?? DEFAULT_UNHEALTHY_BLOCK_DIFF this.serviceSelector = new DiscoveryProviderSelection( @@ -243,12 +247,33 @@ export class DiscoveryProvider { this.web3Manager && this.web3Manager.web3 ) { - // Set current user if it exists - const userAccount = await this.getUserAccount( + const walletOverride = this.enableUserWalletOverride + ? await getUserWalletOverride(this.localStorage) + : undefined + + const web3AccountPromise = this.getUserAccount( this.web3Manager.getWalletAddress() ) - if (userAccount) { - await this.userStateManager.setCurrentUser(userAccount) + + if (walletOverride) { + const overrideAccountPromise = this.getUserAccount(walletOverride) + const [web3User, currentUser] = await Promise.all([ + web3AccountPromise, + overrideAccountPromise + ]) + + if (web3User) { + this.userStateManager.setWeb3User(web3User) + } + if (currentUser) { + await this.userStateManager.setCurrentUser(currentUser) + } + } else { + const currentUser = await web3AccountPromise + if (currentUser) { + this.userStateManager.setWeb3User(currentUser) + await this.userStateManager.setCurrentUser(currentUser) + } } } } @@ -839,21 +864,8 @@ export class DiscoveryProvider { * Return user collections (saved & uploaded) along w/ users for those collections */ async getUserAccount(wallet: string) { - const userIdOverride = this.enableUserIdOverride - ? await getUserIdOverride(this.localStorage) - : undefined - // If override is used, fetch that account instead - if (userIdOverride) { - const req = Requests.getUsers(1, 0, [parseInt(userIdOverride)]) - const res = await this._makeRequest(req) - if (res && res.length > 0 && res[0]) { - return { ...res[0], playlists: [] } as CurrentUser - } - return null - } else { - const req = Requests.getUserAccount(wallet) - return await this._makeRequest(req) - } + const req = Requests.getUserAccount(wallet) + return await this._makeRequest(req) } /** diff --git a/packages/libs/src/services/discoveryProvider/constants.ts b/packages/libs/src/services/discoveryProvider/constants.ts index f95eaad67ac..fa81fc1e64d 100644 --- a/packages/libs/src/services/discoveryProvider/constants.ts +++ b/packages/libs/src/services/discoveryProvider/constants.ts @@ -1,5 +1,7 @@ export const DISCOVERY_PROVIDER_TIMESTAMP = '@audius/libs:discovery-node-timestamp' +export const DISCOVERY_PROVIDER_USER_WALLET_OVERRIDE = + '@audius/user-wallet-override' export const DISCOVERY_SERVICE_NAME = 'discovery-node' export const DEFAULT_UNHEALTHY_BLOCK_DIFF = 15 export const REGRESSED_MODE_TIMEOUT = 2 * 60 * 1000 // two minutes diff --git a/packages/libs/src/userStateManager.ts b/packages/libs/src/userStateManager.ts index 4ff581ecac7..8acb9418aec 100644 --- a/packages/libs/src/userStateManager.ts +++ b/packages/libs/src/userStateManager.ts @@ -19,11 +19,13 @@ type UserStateManagerConfig = { */ export class UserStateManager { currentUser: CurrentUser | null + web3User: CurrentUser | null localStorage?: LocalStorage constructor({ localStorage }: UserStateManagerConfig) { // Should reflect the same fields as discovery node's /users?handle= this.currentUser = null + this.web3User = null this.localStorage = localStorage } @@ -41,14 +43,26 @@ export class UserStateManager { } } + setWeb3User(user: CurrentUser) { + this.web3User = user + } + getCurrentUser() { return this.currentUser } + getWeb3User() { + return this.web3User + } + getCurrentUserId() { return this.currentUser ? this.currentUser.user_id : null } + getWeb3UserId() { + return this.web3User ? this.web3User.user_id : null + } + async clearUser() { this.currentUser = null if (this.localStorage) { diff --git a/packages/web/src/components/nav/desktop/AccountDetails.tsx b/packages/web/src/components/nav/desktop/AccountDetails.tsx index 935e168f439..8acf880c051 100644 --- a/packages/web/src/components/nav/desktop/AccountDetails.tsx +++ b/packages/web/src/components/nav/desktop/AccountDetails.tsx @@ -1,12 +1,15 @@ +import { FeatureFlags } from '@audius/common/services' import { accountSelectors } from '@audius/common/store' import { Text } from '@audius/harmony' import { AvatarLegacy } from 'components/avatar/AvatarLegacy' import { TextLink, UserLink } from 'components/link' +import { useFlag } from 'hooks/useRemoteConfig' import { useSelector } from 'utils/reducer' import { SIGN_IN_PAGE, profilePage } from 'utils/route' import styles from './AccountDetails.module.css' +import { AccountSwitcher } from './AccountSwitcher/AccountSwitcher' const { getAccountUser } = accountSelectors @@ -17,6 +20,7 @@ const messages = { export const AccountDetails = () => { const account = useSelector((state) => getAccountUser(state)) + const { isEnabled: isManagerModeEnabled } = useFlag(FeatureFlags.MANAGER_MODE) const profileLink = profilePage(account?.handle ?? '') @@ -57,6 +61,7 @@ export const AccountDetails = () => { )} + {isManagerModeEnabled && account ? : null} ) diff --git a/packages/web/src/components/nav/desktop/AccountSwitcher/AccountListContent.tsx b/packages/web/src/components/nav/desktop/AccountSwitcher/AccountListContent.tsx new file mode 100644 index 00000000000..2a09745ce24 --- /dev/null +++ b/packages/web/src/components/nav/desktop/AccountSwitcher/AccountListContent.tsx @@ -0,0 +1,103 @@ +import { useCallback } from 'react' + +import { ID, ManagedUserMetadata, UserMetadata } from '@audius/common/models' +import { Box, Flex, IconUserArrowRotate, Text } from '@audius/harmony' +import styled from '@emotion/styled' + +import { AccountSwitcherRow } from './AccountSwitcherRow' + +const messages = { + switchAccount: 'Switch Account', + managedAccounts: 'Managed Accounts' +} + +export type AccountListContentProps = { + accounts: ManagedUserMetadata[] + managerAccount: UserMetadata + currentUserId: ID + onAccountSelected: (user: UserMetadata) => void +} + +const StyledList = styled.ul` + all: unset; + list-style: none; +` + +export const AccountListContent = ({ + accounts, + managerAccount, + currentUserId, + onAccountSelected +}: AccountListContentProps) => { + const onUserSelected = useCallback( + (user: UserMetadata) => { + if (user.user_id !== currentUserId) { + onAccountSelected(user) + } + }, + [currentUserId, onAccountSelected] + ) + return ( + + + + + {messages.switchAccount} + + + +
  • onUserSelected(managerAccount)} + > + +
  • + + + {messages.managedAccounts} + + + + {accounts.map(({ user }, i) => ( +
  • onUserSelected(user)} + tabIndex={-1} + > + + + +
  • + ))} +
    +
    + ) +} diff --git a/packages/web/src/components/nav/desktop/AccountSwitcher/AccountSwitcher.tsx b/packages/web/src/components/nav/desktop/AccountSwitcher/AccountSwitcher.tsx new file mode 100644 index 00000000000..e3cd4d4b494 --- /dev/null +++ b/packages/web/src/components/nav/desktop/AccountSwitcher/AccountSwitcher.tsx @@ -0,0 +1,85 @@ +import { useCallback, useMemo, useRef, useState } from 'react' + +import { + useGetCurrentUserId, + useGetCurrentWeb3User, + useGetManagedAccounts +} from '@audius/common/api' +import { useAccountSwitcher } from '@audius/common/hooks' +import { UserMetadata } from '@audius/common/models' +import { Flex, IconButton, IconCaretDown, Popup } from '@audius/harmony' + +import { AccountListContent } from './AccountListContent' + +export const AccountSwitcher = () => { + const [isExpanded, setIsExpanded] = useState(false) + + const { data: currentWeb3User } = useGetCurrentWeb3User({}) + const { data: currentUserId } = useGetCurrentUserId({}) + + const { switchAccount } = useAccountSwitcher() + + const onAccountSelected = useCallback( + (user: UserMetadata) => { + switchAccount(user) + }, + [switchAccount] + ) + + const web3UserId = currentWeb3User?.user_id ?? null + + const { data: managedAccounts = [] } = useGetManagedAccounts( + { userId: web3UserId! }, + { disabled: !web3UserId } + ) + + const parentElementRef = useRef(null) + const onClickExpander = useCallback( + () => setIsExpanded((expanded) => !expanded), + [] + ) + + const accounts = useMemo(() => { + return managedAccounts.filter(({ grant }) => grant.is_approved) + }, [managedAccounts]) + + return !currentWeb3User || !currentUserId || accounts.length === 0 ? null : ( + + + { + if (target instanceof Element && parentElementRef.current) { + return parentElementRef.current.contains(target) + } + return false + }} + anchorRef={parentElementRef} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'left' }} + dismissOnMouseLeave={false} + isVisible={isExpanded} + onClose={() => setIsExpanded(false)} + > + + + + ) +} diff --git a/packages/web/src/components/nav/desktop/AccountSwitcher/AccountSwitcherRow.module.css b/packages/web/src/components/nav/desktop/AccountSwitcher/AccountSwitcherRow.module.css new file mode 100644 index 00000000000..e80cfb45737 --- /dev/null +++ b/packages/web/src/components/nav/desktop/AccountSwitcher/AccountSwitcherRow.module.css @@ -0,0 +1,27 @@ +:root { + --profile-picture-size: var(--harmony-unit-12); +} + +.profilePictureWrapper { + height: var(--profile-picture-size); + width: var(--profile-picture-size); + flex: 0 0 var(--profile-picture-size); +} + +.profilePicture { + box-sizing: border-box; + height: var(--profile-picture-size); + width: var(--profile-picture-size); + flex: 0 0 var(--profile-picture-size); + border: 1px solid var(--harmony-n-100); + border-radius: 50%; + + background-size: cover; + background-position: center; + background-repeat: no-repeat; + background-color: var(--default-profile-picture-background); +} + +.profilePictureSkeleton { + border-radius: 50%; +} diff --git a/packages/web/src/components/nav/desktop/AccountSwitcher/AccountSwitcherRow.tsx b/packages/web/src/components/nav/desktop/AccountSwitcher/AccountSwitcherRow.tsx new file mode 100644 index 00000000000..9226fff4750 --- /dev/null +++ b/packages/web/src/components/nav/desktop/AccountSwitcher/AccountSwitcherRow.tsx @@ -0,0 +1,89 @@ +import { SquareSizes, UserMetadata } from '@audius/common/models' +import { Flex, Text, useTheme } from '@audius/harmony' +import styled from '@emotion/styled' + +import DynamicImage from 'components/dynamic-image/DynamicImage' +import UserBadges from 'components/user-badges/UserBadges' +import { useProfilePicture } from 'hooks/useUserProfilePicture' + +import styles from './AccountSwitcherRow.module.css' + +export type AccountSwitcherRowProps = { + user: UserMetadata + isSelected?: boolean +} + +const Indicator = styled.div(({ theme }) => ({ + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + width: theme.spacing.xs, + backgroundColor: theme.color.background.accent +})) + +export const AccountSwitcherRow = ({ + user, + isSelected = false +}: AccountSwitcherRowProps) => { + const profilePicture = useProfilePicture( + user.user_id, + SquareSizes.SIZE_150_BY_150 + ) + const { iconSizes, color } = useTheme() + return ( + + {isSelected && } + + + + + {user.name} + + + + {`@${user.handle}`} + + + ) +}