Skip to content

Commit

Permalink
feat(widget): custom token lists in widget (#3390)
Browse files Browse the repository at this point in the history
  • Loading branch information
fairlighteth authored Jan 4, 2024
1 parent 378977c commit 7eabe06
Show file tree
Hide file tree
Showing 17 changed files with 425 additions and 23 deletions.
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { BalancesAndAllowancesUpdater } from '@cowprotocol/balances-and-allowances'
import { TokensListsUpdater, UnsupportedTokensUpdater } from '@cowprotocol/tokens'
import { WidgetTokensListsUpdater, TokensListsUpdater, UnsupportedTokensUpdater } from '@cowprotocol/tokens'
import { useWalletInfo, WalletUpdater } from '@cowprotocol/wallet'

import { GasPriceStrategyUpdater } from 'legacy/state/gas/gas-price-strategy-updater'

import { UploadToIpfsUpdater } from 'modules/appData/updater/UploadToIpfsUpdater'
import { InjectedWidgetUpdater } from 'modules/injectedWidget'
import { InjectedWidgetUpdater, useInjectedWidgetParams } from 'modules/injectedWidget'
import { EthFlowDeadlineUpdater, EthFlowSlippageUpdater } from 'modules/swap/state/EthFlow/updaters'
import { UsdPricesUpdater } from 'modules/usdAmount'

Expand All @@ -31,6 +31,7 @@ import { UserUpdater } from 'common/updaters/UserUpdater'

export function Updaters() {
const { chainId, account } = useWalletInfo()
const { tokenLists, appCode } = useInjectedWidgetParams()

return (
<>
Expand Down Expand Up @@ -59,6 +60,7 @@ export function Updaters() {
<TotalSurplusUpdater />
<UsdPricesUpdater />
<TokensListsUpdater chainId={chainId} />
<WidgetTokensListsUpdater tokenLists={tokenLists} appCode={appCode} />
<UnsupportedTokensUpdater />
<BalancesAndAllowancesUpdater chainId={chainId} account={account} />
</>
Expand Down
29 changes: 29 additions & 0 deletions apps/widget-configurator/src/app/configurator/consts.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,36 @@
import { TradeType } from '@cowprotocol/widget-lib'

import { TokenListItem } from './types'

export const TRADE_MODES = [TradeType.SWAP, TradeType.LIMIT, TradeType.ADVANCED]

// Sourced from https://tokenlists.org/
export const DEFAULT_TOKEN_LISTS: TokenListItem[] = [
{ url: 'https://files.cow.fi/tokens/CowSwap.json', enabled: true },
{ url: 'https://tokens.coingecko.com/uniswap/all.json', enabled: true },
{ url: 'https://tokens.1inch.eth.link', enabled: false },
{ url: 'https://tokenlist.aave.eth.link', enabled: false },
{ url: 'https://datafi.theagora.eth.link', enabled: false },
{ url: 'https://defi.cmc.eth.link', enabled: false },
{ url: 'https://stablecoin.cmc.eth.link', enabled: false },
{ url: 'https://erc20.cmc.eth.link', enabled: false },
{
url: 'https://raw.githubusercontent.com/compound-finance/token-list/master/compound.tokenlist.json',
enabled: false,
},
{ url: 'https://tokenlist.dharma.eth.link', enabled: false },
{ url: 'https://www.gemini.com/uniswap/manifest.json', enabled: false },
{ url: 'https://t2crtokens.eth.link', enabled: false },
{ url: 'https://messari.io/tokenlist/messari-verified', enabled: false },
{ url: 'https://uniswap.mycryptoapi.com', enabled: false },
{ url: 'https://static.optimism.io/optimism.tokenlist.json', enabled: false },
{ url: 'https://app.tryroll.com/tokens.json', enabled: false },
{ url: 'https://raw.githubusercontent.com/SetProtocol/uniswap-tokenlist/main/set.tokenlist.json', enabled: false },
{ url: 'https://synths.snx.eth.link', enabled: false },
{ url: 'https://testnet.tokenlist.eth.link', enabled: false },
{ url: 'https://gateway.ipfs.io/ipns/tokens.uniswap.org', enabled: false },
{ url: 'https://wrapped.tokensoft.eth.link', enabled: false },
]
// TODO: Move default palette to a new lib that only exposes the palette colors.
// This wayit can be consumed by both the configurator and the widget.
export const DEFAULT_LIGHT_PALETTE = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import React, { useState, useCallback, useMemo, Dispatch, SetStateAction } from 'react'

import {
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Button,
OutlinedInput,
InputLabel,
ListItemText,
MenuItem,
FormControl,
Select,
TextField,
SelectChangeEvent,
Chip,
Box,
} from '@mui/material'

import { TokenListItem } from '../types'

const ITEM_HEIGHT = 48
const ITEM_PADDING_TOP = 8
const MenuProps = {
PaperProps: {
style: {
maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
width: 250,
},
},
}

type TokenListControlProps = {
tokenListsState: [TokenListItem[], Dispatch<SetStateAction<TokenListItem[]>>]
}

type CustomList = {
url: string
}

type AddCustomListDialogProps = {
open: boolean
onClose: () => void
onAdd: (newList: CustomList) => void
}

const AddCustomListDialog = ({ open, onClose, onAdd }: AddCustomListDialogProps) => {
const [customList, setCustomList] = useState<CustomList>({ url: '' })
const [errors, setErrors] = useState({ url: false })

const validateURL = (url: string) => {
const pattern = new RegExp(
'^(https?:\\/\\/)?' + // protocol
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name
'((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path
'(\\?[;&a-z\\d%_.~+=-]*)?' + // query string
'(\\#[-a-z\\d_]*)?$',
'i'
) // fragment locator
return !!pattern.test(url)
}

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { id, value } = e.target
setCustomList({ ...customList, [id]: value })

setErrors({ ...errors, url: !validateURL(value) })
}

const handleAdd = () => {
const isUrlValid = validateURL(customList.url)

setErrors({
url: !isUrlValid,
})

if (isUrlValid) {
onAdd(customList)
setCustomList({ url: '' }) // Reset the custom list
onClose()
}
}

return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Add Custom Token List</DialogTitle>
<DialogContent>
<TextField
error={errors.url}
margin="dense"
id="url"
label="List URL"
type="url"
fullWidth
variant="outlined"
value={customList.url}
onChange={handleInputChange}
helperText={errors.url && 'Enter a valid URL'}
required
autoComplete="off"
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={handleAdd}>Add</Button>
</DialogActions>
</Dialog>
)
}

export const TokenListControl = ({ tokenListsState }: TokenListControlProps) => {
const [tokenLists, setTokenLists] = tokenListsState
const [dialogOpen, setDialogOpen] = useState(false)

const handleChange = useCallback(
(event: SelectChangeEvent<string[]>) => {
const selected = event.target.value as string[]

setTokenLists((prev) =>
prev.map((list) => ({
...list,
enabled: selected.includes(list.url),
}))
)
},
[setTokenLists]
)

const handleAddCustomList = useCallback(
(newList: CustomList) => {
const existing = tokenLists.find((list) => list.url.toLowerCase() === newList.url.toLowerCase())

if (existing) return

setTokenLists((prev) => [...prev, { ...newList, enabled: true }])
},
[tokenLists, setTokenLists]
)

const tokenListOptions = useMemo(
() =>
tokenLists
.sort((a, b) => {
if (a.enabled) return -1

return a.url.length > b.url.length ? 1 : -1
})
.map((list) => (
<MenuItem key={list.url} value={list.url}>
<Checkbox checked={list.enabled} />
<ListItemText
primary={list.url}
disableTypography={true}
style={{
fontSize: '13px',
whiteSpace: 'initial',
wordBreak: 'break-word',
}}
/>
</MenuItem>
)),
[tokenLists]
)

return (
<>
<div>
<FormControl sx={{ width: '100%' }}>
<InputLabel id="token-list-chip-label">Active Token Lists</InputLabel>
<Select
labelId="token-list-chip-label"
id="token-list-chip-select"
multiple
value={tokenLists.filter((list) => list.enabled).map((list) => list.url)}
onChange={handleChange}
input={<OutlinedInput label="Active Token Lists" />}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((url) => (
<Chip key={url} label={url} />
))}
</Box>
)}
MenuProps={MenuProps}
>
{tokenListOptions}
</Select>
</FormControl>

<AddCustomListDialog open={dialogOpen} onClose={() => setDialogOpen(false)} onAdd={handleAddCustomList} />
</div>
<Button sx={{ width: '100%' }} variant="outlined" onClick={() => setDialogOpen(true)}>
Add Custom List
</Button>
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function useWidgetParamsAndSettings(
sellTokenAmount,
buyToken,
buyTokenAmount,
tokenLists,
customColors,
defaultColors,
} = configuratorState
Expand All @@ -43,6 +44,7 @@ export function useWidgetParamsAndSettings(
height: '640px',
provider,
chainId,
tokenLists: tokenLists.filter((list) => list.enabled).map((list) => ({ url: list.url })),
env: getEnv(),
tradeType: currentTradeType,
sell: { asset: sellToken, amount: sellTokenAmount ? sellTokenAmount.toString() : undefined },
Expand Down
20 changes: 14 additions & 6 deletions apps/widget-configurator/src/app/configurator/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,21 @@ import ListItemText from '@mui/material/ListItemText'
import Typography from '@mui/material/Typography'
import { useAccount, useNetwork } from 'wagmi'

import { TRADE_MODES } from './consts'
import { DEFAULT_TOKEN_LISTS, TRADE_MODES } from './consts'
import { CurrencyInputControl } from './controls/CurrencyInputControl'
import { CurrentTradeTypeControl } from './controls/CurrentTradeTypeControl'
import { NetworkControl, NetworkOption, NetworkOptions } from './controls/NetworkControl'
import { PaletteControl } from './controls/PaletteControl'
import { ThemeControl } from './controls/ThemeControl'
import { TokenListControl } from './controls/TokenListControl' // Adjust the import path as needed
import { TradeModesControl } from './controls/TradeModesControl'
import { useColorPaletteManager } from './hooks/useColorPaletteManager'
import { useEmbedDialogState } from './hooks/useEmbedDialogState'
import { useProvider } from './hooks/useProvider'
import { useSyncWidgetNetwork } from './hooks/useSyncWidgetNetwork'
import { useWidgetParamsAndSettings } from './hooks/useWidgetParamsAndSettings'
import { ContentStyled, DrawerStyled, WalletConnectionWrapper, WrapperStyled } from './styled'
import { ConfiguratorState } from './types'
import { ConfiguratorState, TokenListItem } from './types'

import { ColorModeContext } from '../../theme/ColorModeContext'
import { web3Modal } from '../../wagmiConfig'
Expand Down Expand Up @@ -72,6 +73,9 @@ export function Configurator({ title }: { title: string }) {
const [buyToken] = buyTokenState
const [buyTokenAmount] = buyTokenAmountState

const tokenListsState = useState<TokenListItem[]>(DEFAULT_TOKEN_LISTS)
const [tokenLists] = tokenListsState

const paletteManager = useColorPaletteManager(mode)
const { colorPalette, defaultPalette } = paletteManager

Expand All @@ -96,9 +100,9 @@ export function Configurator({ title }: { title: string }) {

const provider = useProvider()

// Don't change chainId in the widget URL if the user is connected to a wallet
// Because useSyncWidgetNetwork() will send a request to change the network
const state: ConfiguratorState = {
// Don't change chainId in the widget URL if the user is connected to a wallet
// Because useSyncWidgetNetwork() will send a request to change the network
chainId: isDisconnected || !walletChainId ? chainId : walletChainId,
theme: mode,
currentTradeType,
Expand All @@ -107,6 +111,7 @@ export function Configurator({ title }: { title: string }) {
sellTokenAmount,
buyToken,
buyTokenAmount,
tokenLists,
customColors: colorPalette,
defaultColors: defaultPalette,
}
Expand Down Expand Up @@ -135,7 +140,7 @@ export function Configurator({ title }: { title: string }) {
e.stopPropagation()
setIsDrawerOpen(true)
}}
style={{ position: 'fixed', bottom: '1.6rem', left: '1.6rem' }}
sx={{ position: 'fixed', bottom: '1.6rem', left: '1.6rem' }}
>
<EditIcon />
</Fab>
Expand All @@ -160,6 +165,8 @@ export function Configurator({ title }: { title: string }) {

<NetworkControl state={networkControlState} />

<TokenListControl tokenListsState={tokenListsState} />

<Divider variant="middle">Token selection</Divider>

<CurrencyInputControl
Expand All @@ -176,7 +183,7 @@ export function Configurator({ title }: { title: string }) {
color="primary"
aria-label="hide drawer"
onClick={() => setIsDrawerOpen(false)}
style={{ position: 'fixed', top: '1.3rem', left: '26.7rem' }}
sx={{ position: 'fixed', top: '1.3rem', left: '26.7rem' }}
>
<KeyboardDoubleArrowLeftIcon />
</Fab>
Expand All @@ -195,6 +202,7 @@ export function Configurator({ title }: { title: string }) {
target={onClick ? undefined : '_blank'}
rel={onClick ? undefined : 'noopener noreferrer'}
onClick={onClick}
sx={{ width: '100%' }}
>
<ListItemIcon>{icon}</ListItemIcon>
<ListItemText primary={label} />
Expand Down
2 changes: 1 addition & 1 deletion apps/widget-configurator/src/app/configurator/styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const DrawerStyled = (theme: Theme) => ({
width: '29rem',
boxSizing: 'border-box',
display: 'flex',
flexFlow: 'column wrap',
flexFlow: 'column',
gap: '1.6rem',
height: '100%',
border: 0,
Expand Down
Loading

0 comments on commit 7eabe06

Please sign in to comment.