Rewrite localstorage context (#749)

* Rewrite the local storage context into a redux store for user data

* Separate out the mega methods

* Fix infinite loop

* Missing dependency

* Missing dependency, rename version field
This commit is contained in:
Moody Salem 2020-05-12 14:46:58 -04:00 committed by GitHub
parent b28ad2865d
commit 19a53cd999
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 455 additions and 371 deletions

@ -48,6 +48,7 @@
"react-spring": "^8.0.27", "react-spring": "^8.0.27",
"react-use-gesture": "^6.0.14", "react-use-gesture": "^6.0.14",
"rebass": "^4.0.7", "rebass": "^4.0.7",
"redux-localstorage-simple": "^2.2.0",
"styled-components": "^4.2.0", "styled-components": "^4.2.0",
"swr": "0.1.18", "swr": "0.1.18",
"use-media": "^1.4.0" "use-media": "^1.4.0"

@ -16,7 +16,7 @@ import { AutoRow, RowBetween, RowFixed } from '../Row'
import { ROUTER_ADDRESS } from '../../constants' import { ROUTER_ADDRESS } from '../../constants'
import { useTokenAllowance } from '../../data/Allowances' import { useTokenAllowance } from '../../data/Allowances'
import { useAddressBalance, useAllBalances } from '../../contexts/Balances' import { useAddressBalance, useAllBalances } from '../../contexts/Balances'
import { useLocalStorageTokens } from '../../contexts/LocalStorage' import { useAddUserToken, useFetchTokenByAddress } from '../../state/user/hooks'
import { usePair } from '../../data/Reserves' import { usePair } from '../../data/Reserves'
import { useAllTokens, useToken } from '../../contexts/Tokens' import { useAllTokens, useToken } from '../../contexts/Tokens'
import { usePendingApproval, useTransactionAdder } from '../../contexts/Transactions' import { usePendingApproval, useTransactionAdder } from '../../contexts/Transactions'
@ -113,7 +113,8 @@ function ExchangePage({ sendingInput = false, history, params }: ExchangePagePro
} }
// ensure input + output tokens are added to localstorage // ensure input + output tokens are added to localstorage
const [, { fetchTokenByAddress, addToken }] = useLocalStorageTokens() const fetchTokenByAddress = useFetchTokenByAddress()
const addToken = useAddUserToken()
const allTokens = useAllTokens() const allTokens = useAllTokens()
const inputTokenAddress = fieldData[Field.INPUT].address const inputTokenAddress = fieldData[Field.INPUT].address
useEffect(() => { useEffect(() => {

@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { Send, Sun, Moon } from 'react-feather' import { Send, Sun, Moon } from 'react-feather'
import { useDarkModeManager } from '../../contexts/LocalStorage' import { useDarkModeManager } from '../../state/user/hooks'
import { ButtonSecondary } from '../Button' import { ButtonSecondary } from '../Button'

@ -14,7 +14,7 @@ import { isMobile } from 'react-device-detect'
import { YellowCard } from '../Card' import { YellowCard } from '../Card'
import { useWeb3React } from '../../hooks' import { useWeb3React } from '../../hooks'
import { useAddressBalance } from '../../contexts/Balances' import { useAddressBalance } from '../../contexts/Balances'
import { useDarkModeManager } from '../../contexts/LocalStorage' import { useDarkModeManager } from '../../state/user/hooks'
import Logo from '../../assets/svg/logo.svg' import Logo from '../../assets/svg/logo.svg'
import Wordmark from '../../assets/svg/wordmark.svg' import Wordmark from '../../assets/svg/wordmark.svg'

@ -16,7 +16,7 @@ import { ButtonPrimary, ButtonDropwdown, ButtonDropwdownLight } from '../Button'
import { useToken } from '../../contexts/Tokens' import { useToken } from '../../contexts/Tokens'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { useAddressBalance } from '../../contexts/Balances' import { useAddressBalance } from '../../contexts/Balances'
import { useLocalStoragePairAdder } from '../../contexts/LocalStorage' import { usePairAdder } from '../../state/user/hooks'
import { usePair } from '../../data/Reserves' import { usePair } from '../../data/Reserves'
const Fields = { const Fields = {
@ -35,7 +35,7 @@ function PoolFinder({ history }: RouteComponentProps) {
const token1: Token = useToken(token1Address) const token1: Token = useToken(token1Address)
const pair: Pair = usePair(token0, token1) const pair: Pair = usePair(token0, token1)
const addPair = useLocalStoragePairAdder() const addPair = usePairAdder()
useEffect(() => { useEffect(() => {
if (pair) { if (pair) {

@ -24,7 +24,12 @@ import { RowBetween, RowFixed, AutoRow } from '../Row'
import { isAddress } from '../../utils' import { isAddress } from '../../utils'
import { useWeb3React } from '../../hooks' import { useWeb3React } from '../../hooks'
import { useLocalStorageTokens, useAllDummyPairs } from '../../contexts/LocalStorage' import {
useAllDummyPairs,
useFetchTokenByAddress,
useAddUserToken,
useRemoveUserAddedToken
} from '../../state/user/hooks'
import { useAllBalances } from '../../contexts/Balances' import { useAllBalances } from '../../contexts/Balances'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useToken, useAllTokens, ALL_TOKENS } from '../../contexts/Tokens' import { useToken, useAllTokens, ALL_TOKENS } from '../../contexts/Tokens'
@ -179,7 +184,9 @@ function SearchModal({
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [sortDirection, setSortDirection] = useState(true) const [sortDirection, setSortDirection] = useState(true)
const [, { fetchTokenByAddress, addToken, removeTokenByAddress }] = useLocalStorageTokens() const fetchTokenByAddress = useFetchTokenByAddress()
const addToken = useAddUserToken()
const removeTokenByAddress = useRemoveUserAddedToken()
// if the current input is an address, and we don't have the token in context, try to fetch it // if the current input is an address, and we don't have the token in context, try to fetch it
const token = useToken(searchQuery) const token = useToken(searchQuery)

@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import QRCode from 'qrcode.react' import QRCode from 'qrcode.react'
import { useDarkModeManager } from '../../contexts/LocalStorage' import { useDarkModeManager } from '../../state/user/hooks'
const QRCodeWrapper = styled.div` const QRCodeWrapper = styled.div`
${({ theme }) => theme.flexColumnNoWrap}; ${({ theme }) => theme.flexColumnNoWrap};

@ -6,7 +6,7 @@ import { useAllTokens } from './Tokens'
import { useWeb3React, useDebounce } from '../hooks' import { useWeb3React, useDebounce } from '../hooks'
import { getEtherBalance, getTokenBalance, isAddress } from '../utils' import { getEtherBalance, getTokenBalance, isAddress } from '../utils'
import { useAllDummyPairs } from './LocalStorage' import { useAllDummyPairs } from '../state/user/hooks'
const LOCAL_STORAGE_KEY = 'BALANCES' const LOCAL_STORAGE_KEY = 'BALANCES'
const SHORT_BLOCK_TIMEOUT = (60 * 2) / 15 // in seconds, represented as a block number delta const SHORT_BLOCK_TIMEOUT = (60 * 2) / 15 // in seconds, represented as a block number delta

@ -1,325 +0,0 @@
import React, { createContext, useContext, useMemo, useCallback, useEffect, useState } from 'react'
import { Token, Pair, TokenAmount, JSBI, WETH, ChainId } from '@uniswap/sdk'
import { getTokenDecimals, getTokenSymbol, getTokenName, isAddress } from '../utils'
import { useWeb3React } from '@web3-react/core'
import { useAllTokens } from './Tokens'
enum LocalStorageKeys {
VERSION = 'version',
LAST_SAVED = 'lastSaved',
BETA_MESSAGE_DISMISSED = 'betaMessageDismissed',
MIGRATION_MESSAGE_DISMISSED = 'migrationMessageDismissed',
DARK_MODE = 'darkMode',
TOKENS = 'tokens',
PAIRS = 'pairs'
}
function useLocalStorage<T, S = T>(
key: LocalStorageKeys,
defaultValue: T,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ serialize, deserialize }: { serialize: (toSerialize: T) => S; deserialize: (toDeserialize: S) => T } = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
serialize: (toSerialize): S => (toSerialize as unknown) as S,
deserialize: (toDeserialize): T => (toDeserialize as unknown) as T
}
): [T, (value: T) => void] {
const [value, setValue] = useState(() => {
try {
return deserialize(JSON.parse(window.localStorage.getItem(key))) ?? defaultValue
} catch {
return defaultValue
}
})
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(serialize(value)))
} catch {}
}, [key, serialize, value])
return [value, setValue]
}
interface SerializedToken {
chainId: number
address: string
decimals: number
symbol: string
name: string
}
function serializeToken(token: Token): SerializedToken {
return {
chainId: token.chainId,
address: token.address,
decimals: token.decimals,
symbol: token.symbol,
name: token.name
}
}
function deserializeToken(serializedToken: SerializedToken): Token {
return new Token(
serializedToken.chainId,
serializedToken.address,
serializedToken.decimals,
serializedToken.symbol,
serializedToken.name
)
}
const LocalStorageContext = createContext<[any, any]>([{}, {}])
function useLocalStorageContext() {
return useContext(LocalStorageContext)
}
export default function Provider({ children }: { children: React.ReactNode }) {
// global localstorage state
const [version, setVersion] = useLocalStorage<number>(LocalStorageKeys.VERSION, 0)
const [lastSaved, setLastSaved] = useLocalStorage<number>(LocalStorageKeys.LAST_SAVED, Math.floor(Date.now() / 1000))
const [betaMessageDismissed, setBetaMessageDismissed] = useLocalStorage<boolean>(
LocalStorageKeys.BETA_MESSAGE_DISMISSED,
false
)
const [migrationMessageDismissed, setMigrationMessageDismissed] = useLocalStorage<boolean>(
LocalStorageKeys.MIGRATION_MESSAGE_DISMISSED,
false
)
const [darkMode, setDarkMode] = useLocalStorage<boolean>(
LocalStorageKeys.DARK_MODE,
window?.matchMedia('(prefers-color-scheme: dark)')?.matches ? true : false
)
const [tokens, setTokens] = useLocalStorage<Token[], SerializedToken[]>(LocalStorageKeys.TOKENS, [], {
serialize: (tokens: Token[]) => tokens.map(serializeToken),
deserialize: (serializedTokens: SerializedToken[]) => serializedTokens.map(deserializeToken)
})
const [pairs, setPairs] = useLocalStorage<Token[][], SerializedToken[][]>(LocalStorageKeys.PAIRS, [], {
serialize: (nestedTokens: Token[][]) => nestedTokens.map(tokens => tokens.map(serializeToken)),
deserialize: (serializedNestedTokens: SerializedToken[][]) =>
serializedNestedTokens.map(serializedTokens => serializedTokens.map(deserializeToken))
})
return (
<LocalStorageContext.Provider
value={useMemo(
() => [
{ version, lastSaved, betaMessageDismissed, migrationMessageDismissed, darkMode, tokens, pairs },
{
setVersion,
setLastSaved,
setBetaMessageDismissed,
setMigrationMessageDismissed,
setDarkMode,
setTokens,
setPairs
}
],
[
version,
lastSaved,
betaMessageDismissed,
migrationMessageDismissed,
darkMode,
tokens,
pairs,
setVersion,
setLastSaved,
setBetaMessageDismissed,
setMigrationMessageDismissed,
setDarkMode,
setTokens,
setPairs
]
)}
>
{children}
</LocalStorageContext.Provider>
)
}
export function Updater() {
const [, { setDarkMode }] = useLocalStorageContext()
useEffect(() => {
const darkHandler = (match: MediaQueryListEvent) => {
if (match.matches) {
setDarkMode(true)
}
}
const lightHandler = (match: MediaQueryListEvent) => {
if (match.matches) {
setDarkMode(false)
}
}
window?.matchMedia('(prefers-color-scheme: dark)')?.addListener(darkHandler)
window?.matchMedia('(prefers-color-scheme: light)')?.addListener(lightHandler)
return () => {
window?.matchMedia('(prefers-color-scheme: dark)')?.removeListener(darkHandler)
window?.matchMedia('(prefers-color-scheme: light)')?.removeListener(lightHandler)
}
}, [setDarkMode])
return null
}
export function useBetaMessageManager() {
const [{ betaMessageDismissed }, { setBetaMessageDismissed }] = useLocalStorageContext()
const dismissBetaMessage = useCallback(() => {
setBetaMessageDismissed(true)
}, [setBetaMessageDismissed])
return [!betaMessageDismissed, dismissBetaMessage]
}
export function useMigrationMessageManager() {
const [{ migrationMessageDismissed }, { setMigrationMessageDismissed }] = useLocalStorageContext()
const dismissMigrationMessage = useCallback(() => {
setMigrationMessageDismissed(true)
}, [setMigrationMessageDismissed])
return [!migrationMessageDismissed, dismissMigrationMessage]
}
export function useDarkModeManager() {
const [{ darkMode }, { setDarkMode }] = useLocalStorageContext()
const toggleSetDarkMode = useCallback(
value => {
setDarkMode(typeof value === 'boolean' ? value : !darkMode)
},
[darkMode, setDarkMode]
)
return [darkMode, toggleSetDarkMode]
}
export function useLocalStorageTokens(): [
Token[],
{
fetchTokenByAddress: (address: string) => Promise<Token | null>
addToken: (token: Token) => void
removeTokenByAddress: (chainId: number, address: string) => void
}
] {
const { library, chainId } = useWeb3React()
const [{ tokens }, { setTokens }] = useLocalStorageContext()
const fetchTokenByAddress = useCallback(
async (address: string) => {
const [decimals, symbol, name] = await Promise.all([
getTokenDecimals(address, library).catch(() => null),
getTokenSymbol(address, library).catch(() => 'UNKNOWN'),
getTokenName(address, library).catch(() => 'Unknown')
])
if (decimals === null) {
return null
} else {
return new Token(chainId, address, decimals, symbol, name)
}
},
[library, chainId]
)
const addToken = useCallback(
(token: Token) => {
setTokens(tokens => tokens.filter(currentToken => !currentToken.equals(token)).concat([token]))
},
[setTokens]
)
const removeTokenByAddress = useCallback(
(chainId: number, address: string) => {
setTokens(tokens =>
tokens.filter(
currentToken => !(currentToken.chainId === chainId && currentToken.address === isAddress(address))
)
)
},
[setTokens]
)
return [tokens, { fetchTokenByAddress, addToken, removeTokenByAddress }]
}
const ZERO = JSBI.BigInt(0)
export function useLocalStoragePairAdder(): (pair: Pair) => void {
const [, { setPairs }] = useLocalStorageContext()
return useCallback(
(pair: Pair) => {
setPairs(pairs =>
pairs
.filter(tokens => !(tokens[0].equals(pair.token0) && tokens[1].equals(pair.token1)))
.concat([[pair.token0, pair.token1]])
)
},
[setPairs]
)
}
const bases = [
...Object.values(WETH),
new Token(ChainId.MAINNET, '0x6B175474E89094C44Da98b954EedeAC495271d0F', 18, 'DAI', 'Dai Stablecoin'),
new Token(ChainId.MAINNET, '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', 6, 'USDC', 'USD//C')
]
export function useAllDummyPairs(): Pair[] {
const { chainId } = useWeb3React()
const tokens = useAllTokens()
const generatedPairs: Pair[] = useMemo(
() =>
Object.values(tokens)
// select only tokens on the current chain
.filter(token => token.chainId === chainId)
.flatMap(token => {
// for each token on the current chain,
return (
bases
// loop through all the bases valid for the current chain,
.filter(base => base.chainId === chainId)
// to construct pairs of the given token with each base
.map(base => {
if (base.equals(token)) {
return null
} else {
return new Pair(new TokenAmount(base, ZERO), new TokenAmount(token, ZERO))
}
})
.filter(pair => !!pair)
)
}),
[tokens, chainId]
)
const [{ pairs }] = useLocalStorageContext()
const userPairs = useMemo(
() =>
pairs
.filter(tokens => tokens[0].chainId === chainId)
.map(tokens => new Pair(new TokenAmount(tokens[0], ZERO), new TokenAmount(tokens[1], ZERO))),
[pairs, chainId]
)
return useMemo(() => {
return (
generatedPairs
.concat(userPairs)
// filter out duplicate pairs
.filter((pair, i, concatenatedPairs) => {
const firstAppearance = concatenatedPairs.findIndex(
concatenatedPair =>
concatenatedPair.token0.equals(pair.token0) && concatenatedPair.token1.equals(pair.token1)
)
return i === firstAppearance
})
)
}, [generatedPairs, userPairs])
}

@ -1,7 +1,7 @@
import { ChainId, Token, WETH } from '@uniswap/sdk'
import { useMemo } from 'react' import { useMemo } from 'react'
import { ChainId, WETH, Token } from '@uniswap/sdk'
import { useWeb3React } from '../hooks' import { useWeb3React } from '../hooks'
import { useLocalStorageTokens } from './LocalStorage' import { useUserAddedTokens } from '../state/user/hooks'
export const ALL_TOKENS = [ export const ALL_TOKENS = [
// WETH on all chains // WETH on all chains
@ -47,28 +47,22 @@ export const ALL_TOKENS = [
export function useAllTokens(): { [address: string]: Token } { export function useAllTokens(): { [address: string]: Token } {
const { chainId } = useWeb3React() const { chainId } = useWeb3React()
const [localStorageTokens] = useLocalStorageTokens() const userAddedTokens = useUserAddedTokens()
return useMemo(() => { return useMemo(() => {
return ( return (
localStorageTokens userAddedTokens
// filter to the current chain
.filter(token => token.chainId === chainId)
// reduce into all ALL_TOKENS filtered by the current chain // reduce into all ALL_TOKENS filtered by the current chain
.reduce((tokenMap, token) => { .reduce<{ [address: string]: Token }>((tokenMap, token) => {
return { tokenMap[token.address] = token
...tokenMap, return tokenMap
[token.address]: token
}
}, ALL_TOKENS?.[chainId] ?? {}) }, ALL_TOKENS?.[chainId] ?? {})
) )
}, [localStorageTokens, chainId]) }, [userAddedTokens, chainId])
} }
export function useToken(tokenAddress: string): Token { export function useToken(tokenAddress: string): Token {
const tokens = useAllTokens() const tokens = useAllTokens()
const token = tokens?.[tokenAddress] return tokens?.[tokenAddress]
return token
} }

@ -7,7 +7,7 @@ import { Provider } from 'react-redux'
import { NetworkContextName } from './constants' import { NetworkContextName } from './constants'
import { isMobile } from 'react-device-detect' import { isMobile } from 'react-device-detect'
import LocalStorageContextProvider, { Updater as LocalStorageContextUpdater } from './contexts/LocalStorage' import { Updater as LocalStorageContextUpdater } from './state/user/hooks'
import TransactionContextProvider, { Updater as TransactionContextUpdater } from './contexts/Transactions' import TransactionContextProvider, { Updater as TransactionContextUpdater } from './contexts/Transactions'
import BalancesContextProvider, { Updater as BalancesContextUpdater } from './contexts/Balances' import BalancesContextProvider, { Updater as BalancesContextUpdater } from './contexts/Balances'
import App from './pages/App' import App from './pages/App'
@ -37,11 +37,9 @@ ReactGA.pageview(window.location.pathname + window.location.search)
function ContextProviders({ children }: { children: React.ReactNode }) { function ContextProviders({ children }: { children: React.ReactNode }) {
return ( return (
<LocalStorageContextProvider> <TransactionContextProvider>
<TransactionContextProvider> <BalancesContextProvider>{children}</BalancesContextProvider>
<BalancesContextProvider>{children}</BalancesContextProvider> </TransactionContextProvider>
</TransactionContextProvider>
</LocalStorageContextProvider>
) )
} }

@ -31,7 +31,7 @@ import { ROUTER_ADDRESS } from '../../constants'
import { getRouterContract, calculateGasMargin, calculateSlippageAmount } from '../../utils' import { getRouterContract, calculateGasMargin, calculateSlippageAmount } from '../../utils'
import { BigNumber } from '@ethersproject/bignumber' import { BigNumber } from '@ethersproject/bignumber'
import { usePair } from '../../data/Reserves' import { usePair } from '../../data/Reserves'
import { useLocalStorageTokens } from '../../contexts/LocalStorage' import { useAddUserToken, useFetchTokenByAddress } from '../../state/user/hooks'
import { useAllTokens } from '../../contexts/Tokens' import { useAllTokens } from '../../contexts/Tokens'
// denominated in bips // denominated in bips
@ -187,7 +187,9 @@ function AddLiquidity({ token0, token1 }: AddLiquidityProps) {
} }
// ensure input + output tokens are added to localstorage // ensure input + output tokens are added to localstorage
const [, { fetchTokenByAddress, addToken }] = useLocalStorageTokens() const fetchTokenByAddress = useFetchTokenByAddress()
const addToken = useAddUserToken()
const allTokens = useAllTokens() const allTokens = useAllTokens()
const inputTokenAddress = fieldData[Field.INPUT].address const inputTokenAddress = fieldData[Field.INPUT].address
useEffect(() => { useEffect(() => {

@ -16,7 +16,7 @@ import { AutoColumn, ColumnCenter } from '../../components/Column'
import { useWeb3React } from '@web3-react/core' import { useWeb3React } from '@web3-react/core'
import { useAllBalances, useAccountLPBalances } from '../../contexts/Balances' import { useAllBalances, useAccountLPBalances } from '../../contexts/Balances'
import { usePair } from '../../data/Reserves' import { usePair } from '../../data/Reserves'
import { useAllDummyPairs } from '../../contexts/LocalStorage' import { useAllDummyPairs } from '../../state/user/hooks'
const Positions = styled.div` const Positions = styled.div`
position: relative; position: relative;

@ -1,10 +1,17 @@
import { configureStore } from '@reduxjs/toolkit' import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'
import application from './application/reducer' import application from './application/reducer'
import user from './user/reducer'
import { save, load } from 'redux-localstorage-simple'
const PERSISTED_KEYS: string[] = ['user']
const store = configureStore({ const store = configureStore({
reducer: { reducer: {
application application,
} user
},
middleware: [...getDefaultMiddleware(), save({ states: PERSISTED_KEYS })],
preloadedState: load({ states: PERSISTED_KEYS })
}) })
export default store export default store

25
src/state/user/actions.ts Normal file

@ -0,0 +1,25 @@
import { createAction } from '@reduxjs/toolkit'
export interface SerializedToken {
chainId: number
address: string
decimals: number
symbol: string
name: string
}
export interface SerializedPair {
token0: SerializedToken
token1: SerializedToken
}
export const updateVersion = createAction<void>('updateVersion')
export const updateMatchesDarkMode = createAction<{ matchesDarkMode: boolean }>('updateMatchesDarkMode')
export const updateUserDarkMode = createAction<{ userDarkMode: boolean }>('updateUserDarkMode')
export const addSerializedToken = createAction<{ serializedToken: SerializedToken }>('addSerializedToken')
export const removeSerializedToken = createAction<{ chainId: number; address: string }>('removeSerializedToken')
export const addSerializedPair = createAction<{ serializedPair: SerializedPair }>('addSerializedPair')
export const removeSerializedPair = createAction<{ chainId: number; tokenAAddress: string; tokenBAddress: string }>(
'removeSerializedPair'
)
export const dismissBetaMessage = createAction<void>('dismissBetaMessage')

235
src/state/user/hooks.tsx Normal file

@ -0,0 +1,235 @@
import { ChainId, JSBI, Pair, Token, TokenAmount, WETH } from '@uniswap/sdk'
import { useWeb3React } from '@web3-react/core'
import { useCallback, useEffect, useMemo } from 'react'
import { shallowEqual, useDispatch, useSelector } from 'react-redux'
import { useAllTokens } from '../../contexts/Tokens'
import { getTokenDecimals, getTokenName, getTokenSymbol } from '../../utils'
import { AppDispatch, AppState } from '../index'
import {
addSerializedPair,
addSerializedToken,
dismissBetaMessage,
removeSerializedToken,
SerializedPair,
SerializedToken,
updateMatchesDarkMode,
updateUserDarkMode,
updateVersion
} from './actions'
function serializeToken(token: Token): SerializedToken {
return {
chainId: token.chainId,
address: token.address,
decimals: token.decimals,
symbol: token.symbol,
name: token.name
}
}
function deserializeToken(serializedToken: SerializedToken): Token {
return new Token(
serializedToken.chainId,
serializedToken.address,
serializedToken.decimals,
serializedToken.symbol,
serializedToken.name
)
}
export function Updater() {
const dispatch = useDispatch<AppDispatch>()
useEffect(() => {
dispatch(updateVersion())
}, [dispatch])
// keep dark mode in sync with the system
useEffect(() => {
const darkHandler = (match: MediaQueryListEvent) => {
dispatch(updateMatchesDarkMode({ matchesDarkMode: match.matches }))
}
const match = window?.matchMedia('(prefers-color-scheme: dark)')
dispatch(updateMatchesDarkMode({ matchesDarkMode: match.matches }))
match?.addEventListener('change', darkHandler)
return () => {
match?.removeEventListener('change', darkHandler)
}
}, [dispatch])
return null
}
// this currently isn't used anywhere, but is kept as an example of how to store/update a simple boolean
export function useBetaMessageManager() {
const betaMessageDismissed = useSelector<AppState, boolean>(state => state.user.betaMessageDismissed)
const dispatch = useDispatch<AppDispatch>()
const wrappedDismissBetaMessage = useCallback(() => {
dispatch(dismissBetaMessage())
}, [dispatch])
return [!betaMessageDismissed, wrappedDismissBetaMessage]
}
export function useIsDarkMode(): boolean {
const { userDarkMode, matchesDarkMode } = useSelector<AppState, { userDarkMode: boolean; matchesDarkMode: boolean }>(
({ user: { matchesDarkMode, userDarkMode } }) => ({
userDarkMode,
matchesDarkMode
}),
shallowEqual
)
return userDarkMode === null ? matchesDarkMode : userDarkMode
}
export function useDarkModeManager(): [boolean, () => void] {
const dispatch = useDispatch<AppDispatch>()
const darkMode = useIsDarkMode()
const toggleSetDarkMode = useCallback(() => {
dispatch(updateUserDarkMode({ userDarkMode: !darkMode }))
}, [darkMode, dispatch])
return [darkMode, toggleSetDarkMode]
}
export function useFetchTokenByAddress(): (address: string) => Promise<Token | null> {
const { library, chainId } = useWeb3React()
return useCallback(
async (address: string) => {
const [decimals, symbol, name] = await Promise.all([
getTokenDecimals(address, library).catch(() => null),
getTokenSymbol(address, library).catch(() => 'UNKNOWN'),
getTokenName(address, library).catch(() => 'Unknown')
])
if (decimals === null) {
return null
} else {
return new Token(chainId, address, decimals, symbol, name)
}
},
[library, chainId]
)
}
export function useAddUserToken(): (token: Token) => void {
const dispatch = useDispatch<AppDispatch>()
return useCallback(
(token: Token) => {
dispatch(addSerializedToken({ serializedToken: serializeToken(token) }))
},
[dispatch]
)
}
export function useRemoveUserAddedToken(): (chainId: number, address: string) => void {
const dispatch = useDispatch<AppDispatch>()
return useCallback(
(chainId: number, address: string) => {
dispatch(removeSerializedToken({ chainId, address }))
},
[dispatch]
)
}
export function useUserAddedTokens(): Token[] {
const { chainId } = useWeb3React()
const serializedTokensMap = useSelector<AppState, AppState['user']['tokens']>(({ user: { tokens } }) => tokens)
return useMemo(() => {
return Object.values(serializedTokensMap[chainId] ?? {}).map(deserializeToken)
}, [serializedTokensMap, chainId])
}
const ZERO = JSBI.BigInt(0)
function serializePair(pair: Pair): SerializedPair {
return {
token0: serializeToken(pair.token0),
token1: serializeToken(pair.token1)
}
}
export function usePairAdder(): (pair: Pair) => void {
const dispatch = useDispatch<AppDispatch>()
return useCallback(
(pair: Pair) => {
dispatch(addSerializedPair({ serializedPair: serializePair(pair) }))
},
[dispatch]
)
}
const bases = [
...Object.values(WETH),
new Token(ChainId.MAINNET, '0x6B175474E89094C44Da98b954EedeAC495271d0F', 18, 'DAI', 'Dai Stablecoin'),
new Token(ChainId.MAINNET, '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', 6, 'USDC', 'USD//C')
]
export function useAllDummyPairs(): Pair[] {
const { chainId } = useWeb3React()
const tokens = useAllTokens()
const generatedPairs: Pair[] = useMemo(
() =>
Object.values(tokens)
// select only tokens on the current chain
.filter(token => token.chainId === chainId)
.flatMap(token => {
// for each token on the current chain,
return (
bases
// loop through all the bases valid for the current chain,
.filter(base => base.chainId === chainId)
// to construct pairs of the given token with each base
.map(base => {
if (base.equals(token)) {
return null
} else {
return new Pair(new TokenAmount(base, ZERO), new TokenAmount(token, ZERO))
}
})
.filter(pair => !!pair)
)
}),
[tokens, chainId]
)
const savedSerializedPairs = useSelector<AppState>(({ user: { pairs } }) => pairs)
const userPairs = useMemo(
() =>
Object.values<SerializedPair>(savedSerializedPairs[chainId] ?? {}).map(
pair =>
new Pair(
new TokenAmount(deserializeToken(pair.token0), ZERO),
new TokenAmount(deserializeToken(pair.token1), ZERO)
)
),
[savedSerializedPairs, chainId]
)
return useMemo(() => {
const cache: { [pairKey: string]: boolean } = {}
return (
generatedPairs
.concat(userPairs)
// filter out duplicate pairs
.filter(pair => {
const pairKey = `${pair.token0.address}:${pair.token1.address}`
if (cache[pairKey]) {
return false
}
return (cache[pairKey] = true)
})
)
}, [generatedPairs, userPairs])
}

104
src/state/user/reducer.ts Normal file

@ -0,0 +1,104 @@
import { createReducer } from '@reduxjs/toolkit'
import {
addSerializedPair,
addSerializedToken,
removeSerializedPair,
removeSerializedToken,
SerializedPair,
SerializedToken,
updateMatchesDarkMode,
updateUserDarkMode,
updateVersion
} from './actions'
const currentTimestamp = () => new Date().getTime()
interface UserState {
lastVersion: string
userDarkMode: boolean | null // the user's choice for dark mode or light mode
matchesDarkMode: boolean // whether the dark mode media query matches
betaMessageDismissed: boolean
tokens: {
[chainId: number]: {
[address: string]: SerializedToken
}
}
pairs: {
[chainId: number]: {
// keyed by token0Address:token1Address
[key: string]: SerializedPair
}
}
timestamp: number
}
function pairKey(token0Address: string, token1Address: string) {
return `${token0Address};${token1Address}`
}
const initialState: UserState = {
lastVersion: '',
userDarkMode: null,
matchesDarkMode: false,
betaMessageDismissed: false,
tokens: {},
pairs: {},
timestamp: currentTimestamp()
}
export default createReducer(initialState, builder =>
builder
.addCase(updateVersion, state => {
if (state.lastVersion !== process.env.REACT_APP_GIT_COMMIT_HASH) {
state.lastVersion = process.env.REACT_APP_GIT_COMMIT_HASH
// other stuff
}
state.timestamp = currentTimestamp()
})
.addCase(updateUserDarkMode, (state, action) => {
state.userDarkMode = action.payload.userDarkMode
state.timestamp = currentTimestamp()
})
.addCase(updateMatchesDarkMode, (state, action) => {
state.matchesDarkMode = action.payload.matchesDarkMode
state.timestamp = currentTimestamp()
})
.addCase(addSerializedToken, (state, { payload: { serializedToken } }) => {
state.tokens[serializedToken.chainId] = state.tokens[serializedToken.chainId] || {}
state.tokens[serializedToken.chainId][serializedToken.address] = serializedToken
state.timestamp = currentTimestamp()
})
.addCase(removeSerializedToken, (state, { payload: { address, chainId } }) => {
state.tokens[chainId] = state.tokens[chainId] || {}
delete state.tokens[chainId][address]
state.timestamp = currentTimestamp()
})
.addCase(addSerializedPair, (state, { payload: { serializedPair } }) => {
if (
serializedPair.token0.chainId === serializedPair.token1.chainId &&
serializedPair.token0.address !== serializedPair.token1.address
) {
const chainId = serializedPair.token0.chainId
state.pairs[chainId] = state.pairs[chainId] || {}
state.pairs[chainId][pairKey(serializedPair.token0.address, serializedPair.token1.address)] = serializedPair
}
state.timestamp = currentTimestamp()
})
.addCase(removeSerializedPair, (state, { payload: { chainId, tokenAAddress, tokenBAddress } }) => {
if (state.pairs[chainId]) {
// just delete both keys if either exists
delete state.pairs[chainId][pairKey(tokenAAddress, tokenBAddress)]
delete state.pairs[chainId][pairKey(tokenBAddress, tokenAAddress)]
}
state.timestamp = currentTimestamp()
})
)

@ -1,13 +1,16 @@
import React, { useEffect } from 'react' import React, { useEffect, useMemo } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import styled, { import styled, {
ThemeProvider as StyledComponentsThemeProvider, ThemeProvider as StyledComponentsThemeProvider,
createGlobalStyle, createGlobalStyle,
css, css,
DefaultTheme DefaultTheme
} from 'styled-components' } from 'styled-components'
import { AppDispatch, AppState } from '../state'
import { updateUserDarkMode } from '../state/user/actions'
import { getQueryParam, checkSupportedTheme } from '../utils' import { getQueryParam, checkSupportedTheme } from '../utils'
import { SUPPORTED_THEMES } from '../constants' import { SUPPORTED_THEMES } from '../constants'
import { useDarkModeManager } from '../contexts/LocalStorage' import { useIsDarkMode } from '../state/user/hooks'
import { Text, TextProps } from 'rebass' import { Text, TextProps } from 'rebass'
import { Colors } from './styled' import { Colors } from './styled'
@ -115,21 +118,28 @@ export function theme(darkMode: boolean): DefaultTheme {
} }
export default function ThemeProvider({ children }: { children: React.ReactNode }) { export default function ThemeProvider({ children }: { children: React.ReactNode }) {
const [darkMode, toggleDarkMode] = useDarkModeManager() const dispatch = useDispatch<AppDispatch>()
const userDarkMode = useSelector<AppState, boolean | null>(state => state.user.userDarkMode)
const darkMode = useIsDarkMode()
const themeURL = checkSupportedTheme(getQueryParam(window.location, 'theme')) const themeURL = checkSupportedTheme(getQueryParam(window.location, 'theme'))
const themeToRender = themeURL const urlContainsDarkMode: boolean | null = themeURL
? themeURL.toUpperCase() === SUPPORTED_THEMES.DARK ? themeURL.toUpperCase() === SUPPORTED_THEMES.DARK
? true ? true
: themeURL.toUpperCase() === SUPPORTED_THEMES.LIGHT : themeURL.toUpperCase() === SUPPORTED_THEMES.LIGHT
? false ? false
: darkMode : null
: darkMode : null
useEffect(() => { useEffect(() => {
themeURL && toggleDarkMode(themeToRender) if (urlContainsDarkMode !== null && userDarkMode === null) {
}, [toggleDarkMode, themeToRender, themeURL]) dispatch(updateUserDarkMode({ userDarkMode: urlContainsDarkMode }))
}
}, [dispatch, userDarkMode, urlContainsDarkMode])
return <StyledComponentsThemeProvider theme={theme(themeToRender)}>{children}</StyledComponentsThemeProvider> const themeObject = useMemo(() => theme(darkMode), [darkMode])
return <StyledComponentsThemeProvider theme={themeObject}>{children}</StyledComponentsThemeProvider>
} }
const TextWrapper = styled(Text)<{ color: keyof Colors }>` const TextWrapper = styled(Text)<{ color: keyof Colors }>`

@ -5267,6 +5267,11 @@ clone-deep@^4.0.1:
kind-of "^6.0.2" kind-of "^6.0.2"
shallow-clone "^3.0.0" shallow-clone "^3.0.0"
clone-function@>=1.0.1:
version "1.0.6"
resolved "https://registry.yarnpkg.com/clone-function/-/clone-function-1.0.6.tgz#428471937750bca9c48ecbfbc16f6e232f74a03d"
integrity sha1-QoRxk3dQvKnEjsv7wW9uIy90oD0=
clone-response@^1.0.2: clone-response@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b"
@ -12069,6 +12074,11 @@ object-copy@^0.1.0:
define-property "^0.2.5" define-property "^0.2.5"
kind-of "^3.0.3" kind-of "^3.0.3"
object-foreach@>=0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/object-foreach/-/object-foreach-0.1.2.tgz#d7421c5b40e3b6a3ef57ac624368d21d8f8d2dec"
integrity sha1-10IcW0DjtqPvV6xiQ2jSHY+NLew=
object-hash@^2.0.1: object-hash@^2.0.1:
version "2.0.3" version "2.0.3"
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.0.3.tgz#d12db044e03cd2ca3d77c0570d87225b02e1e6ea" resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.0.3.tgz#d12db044e03cd2ca3d77c0570d87225b02e1e6ea"
@ -12097,6 +12107,14 @@ object-keys@~0.4.0:
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-0.4.0.tgz#28a6aae7428dd2c3a92f3d95f21335dd204e0336" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-0.4.0.tgz#28a6aae7428dd2c3a92f3d95f21335dd204e0336"
integrity sha1-KKaq50KN0sOpLz2V8hM13SBOAzY= integrity sha1-KKaq50KN0sOpLz2V8hM13SBOAzY=
object-merge@2.5.1:
version "2.5.1"
resolved "https://registry.yarnpkg.com/object-merge/-/object-merge-2.5.1.tgz#077e8915ce38ea7294788448c5dd339e34df4227"
integrity sha1-B36JFc446nKUeIRIxd0znjTfQic=
dependencies:
clone-function ">=1.0.1"
object-foreach ">=0.1.2"
object-path@0.11.4: object-path@0.11.4:
version "0.11.4" version "0.11.4"
resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.4.tgz#370ae752fbf37de3ea70a861c23bba8915691949" resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.4.tgz#370ae752fbf37de3ea70a861c23bba8915691949"
@ -14316,6 +14334,13 @@ recursive-readdir@2.2.2:
dependencies: dependencies:
minimatch "3.0.4" minimatch "3.0.4"
redux-localstorage-simple@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/redux-localstorage-simple/-/redux-localstorage-simple-2.2.0.tgz#f60a70b0d858626d5db861b3db353ff848427f01"
integrity sha512-BmgnJ3NkxTDvNsnHAZrRVDgODafg2Vtb17q2F2LEhuJ+EderZBJA6aqRsyqZC32BJWpu8PPtferv4Io9dpUf3w==
dependencies:
object-merge "2.5.1"
redux-thunk@^2.3.0: redux-thunk@^2.3.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622"