Commit ed5902ad authored by lynn's avatar lynn Committed by GitHub

feat: second user model PR with wallet properties (#4206)

* init commit

* remove absolute value in date calc

* all the events are now logged properly plus changed native token address to NATIVE

* add documentation line

* remove unnecessary prop

* init

* init

* checkpoint

* checkpoint

* merge

* lint

* cleanup

* wallet user model stuff working as expected now

* add app loaded event and rest of user properties

* fix tests

* change token balances as per kyle rec

* refactor connected wallet state handling + rest of vm comments

* fix redux breaking, revert wallet from set to array
parent ec783fdb
......@@ -5,6 +5,7 @@
* and logged.
*/
export enum EventName {
APP_LOADED = 'Application Loaded',
APPROVE_TOKEN_TXN_SUBMITTED = 'Approve Token Transaction Submitted',
CONNECT_WALLET_BUTTON_CLICKED = 'Connect Wallet Button Clicked',
PAGE_VIEWED = 'Page Viewed',
......@@ -26,15 +27,39 @@ export enum EventName {
}
export enum CUSTOM_USER_PROPERTIES {
WALLET_ADDRESS = 'wallet_address',
WALLET_TYPE = 'wallet_type',
USER_LAST_SEEN_DATE = 'user_last_seen_date',
USER_FIRST_SEEN_DATE = 'user_first_seen_date',
WALLET_CHAIN_IDS = 'all_wallet_chain_ids',
ALL_WALLET_ADDRESSES_CONNECTED = 'all_wallet_addresses_connected',
SCREEN_RESOLUTION = 'screen_resolution',
ALL_WALLET_CHAIN_IDS = 'all_wallet_chain_ids',
BROWSER = 'browser',
LIGHT_MODE = 'light_mode',
DARK_MODE = 'is_dark_mode',
SCREEN_RESOLUTION_HEIGHT = 'screen_resolution_height',
SCREEN_RESOLUTION_WIDTH = 'screen_resolution_width',
WALLET_ADDRESS = 'wallet_address',
WALLET_NATIVE_CURRENCY_BALANCE_USD = 'wallet_native_currency_balance_usd',
WALLET_TOKENS_ADDRESSES = 'wallet_tokens_addresses',
WALLET_TOKENS_SYMBOLS = 'wallet_tokens_symbols',
WALLET_TYPE = 'wallet_type',
}
export enum CUSTOM_USER_PROPERTY_SUFFIXES {
WALLET_TOKEN_AMOUNT_SUFFIX = '_token_amount',
}
export enum CUSTOM_USER_PROPERTY_PREFIXES {
WALLET_CHAIN_IDS_PREFIX = 'wallet_chain_ids_',
WALLET_FIRST_SEEN_DATE_PREFIX = 'first_seen_date_',
WALLET_LAST_SEEN_DATE_PREFIX = 'last_seen_date_',
}
export enum BROWSER {
FIREFOX = 'Mozilla Firefox',
SAMSUNG = 'Samsung Internet',
OPERA = 'Opera',
INTERNET_EXPLORER = 'Microsoft Internet Explorer',
EDGE = 'Microsoft Edge (Legacy)',
EDGE_CHROMIUM = 'Microsoft Edge (Chromium)',
CHROME = 'Google Chrome or Chromium',
SAFARI = 'Apple Safari',
UNKNOWN = 'unknown',
}
export enum WALLET_CONNECTION_RESULT {
......
......@@ -44,6 +44,8 @@ export function sendAnalyticsEvent(eventName: string, eventProperties?: Record<s
track(eventName, eventProperties)
}
type Value = string | number | boolean | string[] | number[]
/**
* Class that exposes methods to mutate the User Model's properties in
* Amplitude that represents the current session's user.
......@@ -67,16 +69,16 @@ class UserModel {
identify(mutate(new Identify()))
}
set(key: string, value: string | number) {
set(key: string, value: Value) {
this.call((event) => event.set(key, value))
}
setOnce(key: string, value: string | number) {
setOnce(key: string, value: Value) {
this.call((event) => event.setOnce(key, value))
}
add(key: string, value: string | number) {
this.call((event) => event.add(key, typeof value === 'number' ? value : 0))
add(key: string, value: number) {
this.call((event) => event.add(key, value))
}
postInsert(key: string, value: string | number) {
......
......@@ -108,10 +108,10 @@ export function CurrencySearch({
}, [allTokens, debouncedQuery])
const [balances, balancesIsLoading] = useAllTokenBalances()
const sortedTokens: Token[] = useMemo(() => {
void balancesIsLoading // creates a new array once balances load to update hooks
return [...filteredTokens].sort(tokenComparator.bind(null, balances))
}, [balances, filteredTokens, balancesIsLoading])
const sortedTokens: Token[] = useMemo(
() => (!balancesIsLoading ? [...filteredTokens].sort(tokenComparator.bind(null, balances)) : []),
[balances, filteredTokens, balancesIsLoading]
)
const filteredSortedTokens = useSortTokensByQuery(debouncedQuery, sortedTokens)
......@@ -126,7 +126,7 @@ export function CurrencySearch({
const s = debouncedQuery.toLowerCase().trim()
if (native.symbol?.toLowerCase()?.indexOf(s) !== -1) {
// Always bump the native token to the top of the list.
return native ? [native, ...filteredSortedTokens.filter((t) => !t.equals(native))] : filteredSortedTokens
return [native, ...filteredSortedTokens.filter((t) => !t.equals(native))]
}
return filteredSortedTokens
}, [debouncedQuery, native, filteredSortedTokens])
......
import { Currency, CurrencyAmount } from '@uniswap/sdk-core'
import * as connectionUtils from 'connection/utils'
import JSBI from 'jsbi'
import { ApplicationModal } from 'state/application/reducer'
import { nativeOnChain } from '../../constants/tokens'
import { render, screen } from '../../test-utils'
import WalletModal from './index'
......@@ -9,6 +12,11 @@ afterEach(() => {
jest.resetModules()
})
const currencyAmount = (token: Currency, amount: number) => CurrencyAmount.fromRawAmount(token, JSBI.BigInt(amount))
const mockEth = () => nativeOnChain(1)
const mockCurrencyAmount = currencyAmount(mockEth(), 1)
const UserAgentMock = jest.requireMock('utils/userAgent')
jest.mock('utils/userAgent', () => ({
isMobile: false,
......@@ -23,6 +31,38 @@ jest.mock('.../../state/application/hooks', () => {
}
})
jest.mock('hooks/useStablecoinPrice', () => {
return {
useStablecoinValue: (_currencyAmount: CurrencyAmount<Currency> | undefined | null) => {
return
},
}
})
jest.mock('state/connection/hooks', () => {
return {
useAllTokenBalances: () => {
return [{}, false]
},
}
})
jest.mock('../../hooks/Tokens', () => {
return {
useAllTokens: () => ({}),
}
})
jest.mock('lib/hooks/useCurrencyBalance', () => {
return {
useCurrencyBalances: (account?: string, currencies?: (Currency | undefined)[]) => {
return [mockCurrencyAmount]
},
}
})
jest.mock('lib/hooks/useNativeCurrency', () => () => mockEth)
jest.mock('@web3-react/core', () => {
const web3React = jest.requireActual('@web3-react/core')
return {
......
import { Trans } from '@lingui/macro'
import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { Connector } from '@web3-react/types'
import { sendAnalyticsEvent, user } from 'components/AmplitudeAnalytics'
import { CUSTOM_USER_PROPERTIES, EventName, WALLET_CONNECTION_RESULT } from 'components/AmplitudeAnalytics/constants'
import {
CUSTOM_USER_PROPERTIES,
CUSTOM_USER_PROPERTY_PREFIXES,
CUSTOM_USER_PROPERTY_SUFFIXES,
EventName,
WALLET_CONNECTION_RESULT,
} from 'components/AmplitudeAnalytics/constants'
import { formatToDecimal, getTokenAddress } from 'components/AmplitudeAnalytics/utils'
import { sendEvent } from 'components/analytics'
import { AutoColumn } from 'components/Column'
import { AutoRow } from 'components/Row'
import { ConnectionType } from 'connection'
import { getConnection, getConnectionName, getIsCoinbaseWallet, getIsInjected, getIsMetaMask } from 'connection/utils'
import { useCallback, useEffect, useState } from 'react'
import { useStablecoinValue } from 'hooks/useStablecoinPrice'
import { useCurrencyBalances } from 'lib/hooks/useCurrencyBalance'
import useNativeCurrency from 'lib/hooks/useNativeCurrency'
import { tokenComparator } from 'lib/hooks/useTokenList/sorting'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ArrowLeft } from 'react-feather'
import { useAllTokenBalances } from 'state/connection/hooks'
import { updateConnectionError } from 'state/connection/reducer'
import { useAppDispatch, useAppSelector } from 'state/hooks'
import { updateSelectedWallet } from 'state/user/reducer'
......@@ -18,6 +31,7 @@ import styled from 'styled-components/macro'
import { isMobile } from 'utils/userAgent'
import { ReactComponent as Close } from '../../assets/images/x.svg'
import { useAllTokens } from '../../hooks/Tokens'
import { useModalIsOpen, useToggleWalletModal } from '../../state/application/hooks'
import { ApplicationModal } from '../../state/application/reducer'
import { ExternalLink, ThemedText } from '../../theme'
......@@ -114,25 +128,51 @@ const WALLET_VIEWS = {
PENDING: 'pending',
}
const sendAnalyticsWalletBalanceUserInfo = (
balances: (CurrencyAmount<Currency> | undefined)[],
nativeCurrencyBalanceUsd: number
) => {
const walletTokensSymbols: string[] = []
const walletTokensAddresses: string[] = []
balances.forEach((currencyAmount) => {
if (currencyAmount !== undefined) {
const tokenBalanceAmount = formatToDecimal(currencyAmount, currencyAmount.currency.decimals)
if (tokenBalanceAmount > 0) {
const tokenAddress = getTokenAddress(currencyAmount.currency)
walletTokensAddresses.push(getTokenAddress(currencyAmount.currency))
walletTokensSymbols.push(currencyAmount.currency.symbol ?? '')
const tokenPrefix = currencyAmount.currency.symbol ?? tokenAddress
user.set(`${tokenPrefix}${CUSTOM_USER_PROPERTY_SUFFIXES.WALLET_TOKEN_AMOUNT_SUFFIX}`, tokenBalanceAmount)
}
}
})
user.set(CUSTOM_USER_PROPERTIES.WALLET_NATIVE_CURRENCY_BALANCE_USD, nativeCurrencyBalanceUsd)
user.set(CUSTOM_USER_PROPERTIES.WALLET_TOKENS_ADDRESSES, walletTokensAddresses)
user.set(CUSTOM_USER_PROPERTIES.WALLET_TOKENS_SYMBOLS, walletTokensSymbols)
}
const sendAnalyticsEventAndUserInfo = (
account: string,
walletType: string,
chainId: number | undefined,
isReconnect: boolean
) => {
const currentDate = new Date().toISOString()
sendAnalyticsEvent(EventName.WALLET_CONNECT_TXN_COMPLETED, {
result: WALLET_CONNECTION_RESULT.SUCCEEDED,
wallet_address: account,
wallet_type: walletType,
is_reconnect: isReconnect,
})
const currentDate = new Date().toISOString()
user.set(CUSTOM_USER_PROPERTIES.WALLET_ADDRESS, account)
user.set(CUSTOM_USER_PROPERTIES.WALLET_TYPE, walletType)
if (chainId) user.postInsert(CUSTOM_USER_PROPERTIES.WALLET_CHAIN_IDS, chainId)
if (chainId) {
user.postInsert(CUSTOM_USER_PROPERTIES.ALL_WALLET_CHAIN_IDS, chainId)
user.postInsert(`${CUSTOM_USER_PROPERTY_PREFIXES.WALLET_CHAIN_IDS_PREFIX}${account}`, chainId)
}
user.postInsert(CUSTOM_USER_PROPERTIES.ALL_WALLET_ADDRESSES_CONNECTED, account)
user.setOnce(CUSTOM_USER_PROPERTIES.USER_FIRST_SEEN_DATE, currentDate)
user.set(CUSTOM_USER_PROPERTIES.USER_LAST_SEEN_DATE, currentDate)
user.setOnce(`${CUSTOM_USER_PROPERTY_PREFIXES.WALLET_FIRST_SEEN_DATE_PREFIX}${account}`, currentDate)
user.set(`${CUSTOM_USER_PROPERTY_PREFIXES.WALLET_LAST_SEEN_DATE_PREFIX}${account}`, currentDate)
}
export default function WalletModal({
......@@ -146,10 +186,11 @@ export default function WalletModal({
}) {
const dispatch = useAppDispatch()
const { connector, account, chainId } = useWeb3React()
const [connectedWallets, updateConnectedWallets] = useConnectedWallets()
const [connectedWallets, addWalletToConnectedWallets] = useConnectedWallets()
const [walletView, setWalletView] = useState(WALLET_VIEWS.ACCOUNT)
const [lastActiveWalletAddress, setLastActiveWalletAddress] = useState<string | undefined>(account)
const [shouldLogWalletBalances, setShouldLogWalletBalances] = useState(false)
const [pendingConnector, setPendingConnector] = useState<Connector | undefined>()
const pendingError = useAppSelector((state) =>
......@@ -159,6 +200,25 @@ export default function WalletModal({
const walletModalOpen = useModalIsOpen(ApplicationModal.WALLET)
const toggleWalletModal = useToggleWalletModal()
const allTokens = useAllTokens()
const [tokenBalances, tokenBalancesIsLoading] = useAllTokenBalances()
const sortedTokens: Token[] = useMemo(
() => (!tokenBalancesIsLoading ? Object.values(allTokens).sort(tokenComparator.bind(null, tokenBalances)) : []),
[tokenBalances, allTokens, tokenBalancesIsLoading]
)
const native = useNativeCurrency()
const sortedTokensWithETH: Currency[] = useMemo(
() =>
// Always bump the native token to the top of the list.
native ? [native, ...sortedTokens.filter((t) => !t.equals(native))] : sortedTokens,
[native, sortedTokens]
)
const balances = useCurrencyBalances(account, sortedTokensWithETH)
const nativeBalance = balances.length > 0 ? balances[0] : null
const nativeCurrencyBalanceUsdValue = useStablecoinValue(nativeBalance)?.toFixed(2)
const openOptions = useCallback(() => {
setWalletView(WALLET_VIEWS.OPTIONS)
}, [setWalletView])
......@@ -180,18 +240,31 @@ export default function WalletModal({
useEffect(() => {
if (account && account !== lastActiveWalletAddress) {
const walletType = getConnectionName(getConnection(connector).type, getIsMetaMask())
if (
const isReconnect =
connectedWallets.filter((wallet) => wallet.account === account && wallet.walletType === walletType).length > 0
) {
sendAnalyticsEventAndUserInfo(account, walletType, chainId, true)
} else {
sendAnalyticsEventAndUserInfo(account, walletType, chainId, false)
updateConnectedWallets({ account, walletType })
}
sendAnalyticsEventAndUserInfo(account, walletType, chainId, isReconnect)
setShouldLogWalletBalances(true)
if (!isReconnect) addWalletToConnectedWallets({ account, walletType })
}
setLastActiveWalletAddress(account)
}, [connectedWallets, updateConnectedWallets, lastActiveWalletAddress, account, connector, chainId])
}, [connectedWallets, addWalletToConnectedWallets, lastActiveWalletAddress, account, connector, chainId])
// Send wallet balance info once it becomes available.
useEffect(() => {
if (!tokenBalancesIsLoading && shouldLogWalletBalances && balances && nativeCurrencyBalanceUsdValue) {
const nativeCurrencyBalanceUsd =
native && nativeCurrencyBalanceUsdValue ? parseFloat(nativeCurrencyBalanceUsdValue) : 0
sendAnalyticsWalletBalanceUserInfo(balances, nativeCurrencyBalanceUsd)
setShouldLogWalletBalances(false)
}
}, [
balances,
nativeCurrencyBalanceUsdValue,
shouldLogWalletBalances,
setShouldLogWalletBalances,
tokenBalancesIsLoading,
native,
])
const tryActivation = useCallback(
async (connector: Connector) => {
......
import { initializeAnalytics } from 'components/AmplitudeAnalytics'
import { PageName } from 'components/AmplitudeAnalytics/constants'
import { sendAnalyticsEvent, user } from 'components/AmplitudeAnalytics'
import { CUSTOM_USER_PROPERTIES, EventName, PageName } from 'components/AmplitudeAnalytics/constants'
import { Trace } from 'components/AmplitudeAnalytics/Trace'
import Loader from 'components/Loader'
import TopLevelModals from 'components/TopLevelModals'
......@@ -8,7 +9,9 @@ import ApeModeQueryParamReader from 'hooks/useApeModeQueryParamReader'
import { lazy, Suspense } from 'react'
import { useEffect } from 'react'
import { Navigate, Route, Routes, useLocation } from 'react-router-dom'
import { useIsDarkMode } from 'state/user/hooks'
import styled from 'styled-components/macro'
import { getBrowser } from 'utils/browser'
import { useAnalyticsReporter } from '../components/analytics'
import ErrorBoundary from '../components/ErrorBoundary'
......@@ -86,6 +89,7 @@ export default function App() {
const { pathname } = useLocation()
const currentPage = getCurrentPageFromLocation(pathname)
const isDarkMode = useIsDarkMode()
useAnalyticsReporter()
initializeAnalytics()
......@@ -94,6 +98,18 @@ export default function App() {
window.scrollTo(0, 0)
}, [pathname])
useEffect(() => {
// TODO(zzmp): add web vitals event properties to app loaded event.
sendAnalyticsEvent(EventName.APP_LOADED)
user.set(CUSTOM_USER_PROPERTIES.BROWSER, getBrowser())
user.set(CUSTOM_USER_PROPERTIES.SCREEN_RESOLUTION_HEIGHT, window.screen.height)
user.set(CUSTOM_USER_PROPERTIES.SCREEN_RESOLUTION_WIDTH, window.screen.width)
}, [])
useEffect(() => {
user.set(CUSTOM_USER_PROPERTIES.DARK_MODE, isDarkMode)
}, [isDarkMode])
return (
<ErrorBoundary>
<DarkModeQueryParamReader />
......
......@@ -18,7 +18,10 @@ const walletsSlice = createSlice({
initialState,
reducers: {
addConnectedWallet(state, { payload }) {
const existsAlready = state.connectedWallets.find((wallet) => shallowEqual(payload, wallet))
if (!existsAlready) {
state.connectedWallets = state.connectedWallets.concat(payload)
}
},
removeConnectedWallet(state, { payload }) {
state.connectedWallets = state.connectedWallets.filter((wallet) => !shallowEqual(wallet, payload))
......
import { BROWSER } from 'components/AmplitudeAnalytics/constants'
// Get browser being used, code comes from: https://developer.mozilla.org/en-US/docs/Web/API/Window/navigator.
export function getBrowser(): string {
const sUsrAg = navigator.userAgent
// The order matters here, and this may report false positives for unlisted browsers.
if (sUsrAg.indexOf('Firefox') > -1) {
return BROWSER.FIREFOX
// "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0"
} else if (sUsrAg.indexOf('SamsungBrowser') > -1) {
return BROWSER.SAMSUNG
// "Mozilla/5.0 (Linux; Android 9; SAMSUNG SM-G955F Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/9.4 Chrome/67.0.3396.87 Mobile Safari/537.36
} else if (sUsrAg.indexOf('Opera') > -1 || sUsrAg.indexOf('OPR') > -1) {
return BROWSER.OPERA
// "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 OPR/57.0.3098.106"
} else if (sUsrAg.indexOf('Trident') > -1) {
return BROWSER.INTERNET_EXPLORER
// "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; Zoom 3.6.0; wbx 1.0.0; rv:11.0) like Gecko"
} else if (sUsrAg.indexOf('Edge') > -1) {
return BROWSER.EDGE
// "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299"
} else if (sUsrAg.indexOf('Edg') > -1) {
return BROWSER.EDGE_CHROMIUM
// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.64
} else if (sUsrAg.indexOf('Chrome') > -1) {
return BROWSER.CHROME
// "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/66.0.3359.181 Chrome/66.0.3359.181 Safari/537.36"
} else if (sUsrAg.indexOf('Safari') > -1) {
return BROWSER.SAFARI
// "Mozilla/5.0 (iPhone; CPU iPhone OS 11_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.0 Mobile/15E148 Safari/604.1 980x1306"
} else {
return BROWSER.UNKNOWN
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment