Compare commits

...

76 Commits

Author SHA1 Message Date
Greg Bugyis
efaefe2e44 fix: Token Details chart: adjust curveTension on 1H chart timeframe (#4632)
Adjust curveTension on 1H chart timeframe
2022-09-16 20:19:20 +03:00
lynn
ed95f1b966 fix: add IP to test project for amplitude (#4644)
* init

* fix lint

* add comment

* lint fix

Co-authored-by: Lynn Yu <lynn.yu@ContangoITs-MacBook-Pro.local>
2022-09-16 13:13:30 -04:00
Jack Short
7ecbc552aa feat: cards resize to uniform height (#4639)
* feat: cards resize to uniform height if differenting heights in collection

* setting uniform height if card is video
2022-09-16 10:45:05 -04:00
Jack Short
dadc997398 chore: updating sprinkles z indices (#4641)
chore: updating sprinkles zindices
2022-09-15 19:10:43 -04:00
lynn
b90d6b5ab0 feat: amplitude swap fixes (#4627)
* init

* add back txn completed event
2022-09-15 17:53:45 -04:00
Jack Short
db5c6f82fd fix: search rerender (#4640) 2022-09-15 17:28:19 -04:00
lynn
f161f9617b feat: Web 924 explore use real data for the sparkline price charts (#4634)
* init

* test

* working w new hook

* simsplify

* fixes to comments

* extra comment remove
2022-09-15 17:27:33 -04:00
Connor McEwen
9d3249e6bd fix: rendering issues with modal/header (#4636)
* fix overlay issue with modal

* fix z index
2022-09-15 15:32:06 -04:00
Jack Short
f1c65afa98 feat: audio video nft collection cards (#4628)
* feat: video asset cards

* feat: audio cards

* square aspect ratio for videos

* adding playsinline
2022-09-15 15:12:23 -04:00
Jack Short
80c1f0cdf9 feat: nft filter bar (#4617)
* initial filter window

* filters

* filter button

* adding all filters to filter check

* sorting exports

* reviewing old css

* change to const

* responding to comments

* removing isMobile

* fixing radio input

* refactoring radio

* refactoring radio

* reusing the same class

* removing unused props

* removing useless clsx

* removing scrollToTop
2022-09-15 13:01:04 -04:00
aballerr
ea0fe83d00 chore: Merging details part 2 (#4594)
* Merging details part 2


Co-authored-by: Alex Ball <alexball@UNISWAP-MAC-038.local>
2022-09-15 12:28:04 -04:00
Greg Bugyis
994836fba7 style: Extend hover on secondary dropdown menu links (#4633)
* Full width hover on secondary menu links

* Remove commented line
2022-09-15 19:27:03 +03:00
Greg Bugyis
41aa1dcb0b feat: NFT Explore Value prop row (#4603)
* NFT Explore: value prop row

* Changes from PR feedback

* Tweak opacity values

* Style cleanup

* Adjust bg image on smaller screen sizes

* Remove unnecessary media query

Co-authored-by: gbugyis <greg@bugyis.com>
2022-09-15 11:48:15 +03:00
Zach Pomerantz
3965d3fdd9 feat: upgrade widget (#4629)
* feat: upgrade widget and use static connectors

* build: upgrade lockfile
2022-09-14 16:29:18 -07:00
cartcrom
ff6fd8a6e9 style: added transition ease to explore (#4616)
* initial commit
* updated transition css
* added opacity constants to theme, switched to using transition duration constants
2022-09-14 17:03:19 -04:00
taycaldwell
0a6906b23e fix: Swaps icons for light theme and dark theme (#4571)
Fix icons for light/dark mode
2022-09-14 14:17:36 -04:00
Charles Bachmeier
c38b5c0ce3 feat: migrate select page assets (#4618)
* add main nft sell page

* remove background

* more precise naming

* Add wallet assets for select page

* update styles while without filter bar

* remove unnecessary useeffect

* deprecate old stlye

* move to common props

* add round helper fn

* use react router link

Co-authored-by: Charlie <charlie@uniswap.org>
Co-authored-by: Charles Bachmeier <charlie@genie.xyz>
2022-09-14 11:07:58 -07:00
Zach Pomerantz
86f3b5a036 feat: caching provider (#4615)
* feat: caching providers

* feat: clear cache per block
2022-09-14 10:05:29 -07:00
Jack Short
382a44f040 feat: adding bag icon to navbar (#4619)
* feat: adding bag icon to navbar

* updating sell icon
2022-09-14 12:40:42 -04:00
Greg Bugyis
2d9604cd14 fix: Tick collisions on token detail price chart (#4623) 2022-09-14 17:19:38 +03:00
cartcrom
7930709bc3 refactor: unnesting token details into sectioned components (#4621)
finished refactoring
2022-09-14 10:19:18 -04:00
Zach Pomerantz
6fe2c92cee fix: reduce price fetching to every 2m (#4622)
* fix: reduce price fetching to every 2m

* fix: grammar
2022-09-13 15:16:01 -07:00
Jack Short
884dee2db3 fix: pointing nft links to the right url hash (#4620) 2022-09-13 17:03:08 -04:00
Zach Pomerantz
1f00c2a9c4 fix: fetch the usd price from parsed amount (#4612) 2022-09-13 10:31:30 -07:00
cartcrom
84070835df feat: token data cache (#4534)
* initial cache construction
* switched to relay cache, updated hooks
* fixed comments
2022-09-13 13:09:12 -04:00
Zach Pomerantz
fb389137e7 feat: adds ethers provider tracing (#4614) 2022-09-13 09:07:47 -07:00
Charles Bachmeier
c14b6a78ae feat: add main nft sell page (#4609)
* add main nft sell page

* remove background

* more precise naming

Co-authored-by: Charlie <charlie@uniswap.org>
Co-authored-by: Charles Bachmeier <charlie@genie.xyz>
2022-09-13 09:07:06 -07:00
Zach Pomerantz
a6c1c49f98 fix: update feature flags on update (#4613) 2022-09-13 09:03:59 -07:00
Charles Bachmeier
7848ad86bd fix: adjust search input width (#4610)
adjust search input width

Co-authored-by: Charlie <charlie@uniswap.org>
2022-09-13 08:39:57 -07:00
Charles Bachmeier
882c15dada fix: bug where changing tabs can open the searchbar (#4607)
* remove onFocus trigger

* typo on comment

Co-authored-by: Charlie <charlie@uniswap.org>
2022-09-12 11:39:07 -07:00
Charles Bachmeier
704ad222d9 feat: add animation to search skeleton and typing state (#4598)
* fade when loading

* fade results between searches

* trending search loading skeleton

* adjust skeleton and cleanup

* remove unused style change

* eslint

* add trendingTokens to query and remove unnecessary returns

* move array map compatibility higher in the call hierarchy

* add feature flag to isLoading param

Co-authored-by: Charlie <charlie@uniswap.org>
2022-09-12 10:47:53 -07:00
Charles Bachmeier
cfee80ce3c feat: Add animations for desktop, tablet, and mobile searchbar (#4584)
* animations working pre cleanup

* all sprinkles

* fix bug with phase1 search content

* new isTablet hook

* add conditional vars

* typeof window

* remove unneeded clsx usage

Co-authored-by: Charlie <charlie@uniswap.org>
2022-09-12 10:09:59 -07:00
github-actions[bot]
eb95cedd72 chore(i18n): new Crowdin translations (#4512)
chore(i18n): synchronize translations from crowdin [skip ci]

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2022-09-12 11:21:30 -04:00
vignesh mohankumar
99a7fb3383 fix: remove refetchOnFocus for routing-api (#4601) 2022-09-09 18:35:12 -04:00
vignesh mohankumar
7f4dbf9346 chore: update smart-order-router (#4600)
* bump SOR

* dedupe
2022-09-09 18:31:41 -04:00
Zach Pomerantz
09b00c9974 fix: use static rpc urls (#4599) 2022-09-09 14:58:13 -07:00
Zach Pomerantz
b74fb8174d feat: optimize AlphaRouter usage (#4596)
* feat: add RouterPreference.PRICE

* docs: add reference to quote params

* fix: cache routers between usages

* fix: tune price inquiries

* fix: note price queries tuning

* fix: clean up params - nix mixed

* fix: defer PRICE tuning
2022-09-09 13:07:53 -07:00
Jordan Frankfurt
a7ec5a64b7 feat(explore): add a simple search debounce (#4595)
add a simple search debounce
2022-09-09 14:09:08 -05:00
Greg Bugyis
c619dcf65d fix: Token chart - inconsistent x-axis time intervals (#4579)
* Use timeScale for x-axis

* Use d3 time intervals for ticks

* Drop slice for now, will handle differently

* scaleTime turned out to be unnecessary

* Use .nice() to help with tick spacing at start/end

Co-authored-by: gbugyis <greg@bugyis.com>
2022-09-09 22:08:57 +03:00
aballerr
1221d88e13 chore: Cypress utility function for selecting feature flags and walletdrop down cypress tests (#4536)
* Adding feature flag utility to cypress and adding wallet cypress tests


Co-authored-by: Alex Ball <alexball@UNISWAP-MAC-038.local>
2022-09-09 14:54:54 -04:00
Charles Bachmeier
48d2ead71d feat: update universal token search and trending tokens endpoint (#4593)
update universal token search and trending tokens endpoint

Co-authored-by: Charlie <charlie@uniswap.org>
2022-09-09 10:28:54 -07:00
aballerr
ed7099bfd6 chore: Merging details page (#4585)
* Initial merge of details page


Co-authored-by: Alex Ball <alexball@UNISWAP-MAC-038.local>
2022-09-09 13:23:41 -04:00
vignesh mohankumar
2604cdfdae fix: only initialize using chain query (#4567) 2022-09-08 12:16:29 -04:00
cartcrom
94dc389812 feat: widget speedbumps on swap review (#4544)
* initial commit
* finished feature
* addressed PR comments
2022-09-07 16:12:35 -04:00
Greg Bugyis
4a8c621f46 feat: add maxHeight to CurrencySearchModal (#4557)
* Add maxHeight to CurrencySearchModal (search only)

* Combine min and maxHeight into single modalHeight value

* Use clearer variable name to distinguish window height value

Co-authored-by: gbugyis <greg@bugyis.com>
2022-09-07 23:08:29 +03:00
aballerr
477af8af4e fix: Making sure all icons are 24px (#4580)
Making all icons size 24px on web status

Co-authored-by: Alex Ball <alexball@UNISWAP-MAC-038.local>
2022-09-07 15:47:24 -04:00
aballerr
a9a7d524aa fix: fixing token colors and token select persistence (#4575)
* fixing token colors and token select persistence

Co-authored-by: Alex Ball <alexball@UNISWAP-MAC-038.local>
2022-09-07 13:35:00 -04:00
aballerr
1cdaff8ddf fix: fixing match design (#4577)
* fixing select token favorite icon to match design



Co-authored-by: Alex Ball <alexball@UNISWAP-MAC-038.local>
2022-09-07 12:44:19 -04:00
aballerr
eeea3d2dcc fix: fixed wallet scrolling issue (#4574)
* fixed scrolling issue for wallet


Co-authored-by: Alex Ball <alexball@UNISWAP-MAC-038.local>
2022-09-07 11:44:33 -04:00
lynn
f46b6a0697 fix: ensure nav bar goes above all other components when scrolling (#4576)
* fix nav bar below other components issue

* respond to comments
2022-09-06 17:18:54 -04:00
lynn
622581ee0a feat: show real values for current network balance (#4565)
* init

* remove card when balance is zero

* remove commented code

* remove commented
2022-09-06 11:50:02 -04:00
Yadong Zhang
eb725f51ce fix: handled liquidity minPrice and maxPrice are empty case. (#4569)
thanks!
2022-09-04 18:37:42 -05:00
lynn
4d4462368b fix: add back usd volume to swap submitted from ui instead of infura hook (#4563)
* init

* typo
2022-09-02 14:56:05 -04:00
lynn
6fe5d4363d fix: remove warning icon on search (#4564)
init
2022-09-02 14:55:50 -04:00
Jordan Frankfurt
b46fa27084 feat: disable branding on swap widget (#4561)
* feat: disable branding on swap widget:

* pr feedback
2022-09-02 10:03:06 -05:00
lynn
ade2440613 fix: missing logo redesign (#4535)
* fix: logos

* different font sizings for diff token sizes from fred

* remove missing symbol

* fix

* fixes to comments

* fix

* use calc instead of fn

* add comment
2022-09-01 17:51:43 -04:00
Jordan Frankfurt
4dc4620b60 feat: integrate widget tx states (#4553)
* feat(widget): sync transaction states

* s

* waiting on type release

* slippage is all that remains

* finalize tx integration

* pr feedback

* pr feedback - else if

* update @uniswap/widgets to 2.7

* add slippage tolerance from transaction.info
2022-09-01 13:17:39 -05:00
aballerr
202c2662f1 fix: Adding socks icon to users profile icon (#4545)
* Adding icon for socks owner for p0 redesign


Co-authored-by: Alex Ball <alexball@UNISWAP-MAC-038.local>
2022-09-01 14:08:49 -04:00
Charles Bachmeier
d2afd71c81 fix: change from flex to inline to fix safari bugs (#4559)
change from flex to inline to fix safari bugs

Co-authored-by: Charlie <charlie@uniswap.org>
2022-09-01 08:29:46 -07:00
aballerr
bad1ce2618 fix: Prod has chevron (#4558)
Remove chevron from prod

Co-authored-by: Alex Ball <alexball@UNISWAP-MAC-038.local>
2022-09-01 10:20:09 -04:00
Vignesh Mohankumar
f194845b2b chore: remove theme.blue200 (#4533)
* chore: remove theme.blue200

* favorite button changes
2022-08-31 20:13:21 -04:00
Jack Short
98d4e108e6 fix: search icon bug (#4556) 2022-08-31 19:35:06 -04:00
lynn
ab43ed1900 fix: connect wallet button chevron styling fixes (#4554)
* fix

* simplify
2022-08-31 18:16:42 -04:00
Greg Bugyis
b147e047a5 fix: Bump height on LineNumberCell and pass header boolean (#4548) 2022-08-31 20:36:23 +03:00
Charles Bachmeier
bbb616f56c feat: auto set cursor on searchbar opening (#4552)
* auto set cursor on searchbar opening

* comment update

Co-authored-by: Charlie <charlie@uniswap.org>
2022-08-31 10:33:51 -07:00
Charles Bachmeier
35a429ea65 feat: highlight first result by default (#4551)
highlight first result by default

Co-authored-by: Charlie <charlie@uniswap.org>
2022-08-31 10:33:33 -07:00
Zach Pomerantz
bd16543c10 build: upgrade @uniswap/widgets to 2.5.0 (#4546)
* build: rm d3-curve-circlecorners entry

* build: upgrade @uniswap/widgets package

* fix: widget input typing
2022-08-31 10:29:50 -07:00
Charles Bachmeier
cbdeae276e feat: adjust bottom navbar padding (#4550)
adjust bottom navbar padding

Co-authored-by: Charlie <charlie@uniswap.org>
2022-08-31 09:57:42 -07:00
lynn
e733113963 fix: modify chart axis to to match crosshairs in year view (#4547)
* fix

* simplify

* refactor

* move logic to monthTickFormatter

* time to date string
2022-08-31 11:31:33 -04:00
aballerr
272b030b89 fix: Fixing border overlap and reducing button size (#4537)
* fixing styling on wallet border

Co-authored-by: Alex Ball <alexball@UNISWAP-MAC-038.local>
2022-08-31 11:11:37 -04:00
Jack Short
472a553d13 fix: catch invalid address token details (#4529) 2022-08-30 17:25:59 -04:00
cartcrom
3a1bff146c feat: adding token safety article link (#4532)
updated article link
2022-08-30 17:24:23 -04:00
lynn
b82b9acc54 fix: remove stablecoin usd val fetch in logging to reduce infura spend (#4543)
remove stablecoin usd val fetch reduce infura
2022-08-30 15:15:49 -04:00
cartcrom
fac3845756 fix: missing segments of price chart (#4541)
fixed missing segments of line
2022-08-30 12:28:52 -04:00
Charles Bachmeier
9381a74f1d feat: nav update to new responsive designs (#4542)
* uppdated mobile nav

* adjust searchbar for new styles

Co-authored-by: Charlie <charlie@uniswap.org>
2022-08-30 09:08:10 -07:00
Charles Bachmeier
f6662a3208 fix: hide right and left border on mobile dropdown (#4540)
hide right and left border on mobile

Co-authored-by: Charlie <charlie@uniswap.org>
2022-08-30 07:15:50 -07:00
226 changed files with 6462 additions and 2033 deletions

1
.env
View File

@@ -5,3 +5,4 @@ REACT_APP_AWS_API_ACCESS_KEY="AKIAYJJWW6AQ47ODATHN"
REACT_APP_AWS_API_ACCESS_SECRET="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"
REACT_APP_AWS_X_API_KEY="z9dReS5UtHu7iTrUsTuWRozLthi3AxOZlvobrIdr14"
REACT_APP_AWS_API_ENDPOINT="https://beta.api.uniswap.org/v1/graphql"
REACT_APP_TEMP_API_URL="https://temp.api.uniswap.org/v1"

View File

@@ -81,6 +81,18 @@
}
]
}
],
"@typescript-eslint/no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@ethersproject/providers",
"message": "Please only use Providers instantiated in constants/providers to improve traceability.",
"allowTypeImports": true
}
]
}
]
}
}

View File

@@ -0,0 +1,50 @@
import { FeatureFlag } from '../../src/featureFlags/flags/featureFlags'
import { getTestSelector } from '../utils'
describe('Wallet Dropdown', () => {
before(() => {
cy.visit('/', { featureFlags: [FeatureFlag.navBar, FeatureFlag.tokenSafety] })
})
it('should change the theme', () => {
cy.get(getTestSelector('web3-status-connected')).click()
cy.get(getTestSelector('wallet-select-theme')).click()
cy.get(getTestSelector('wallet-select-theme')).contains('Light theme').should('exist')
})
it('should select a language', () => {
cy.get(getTestSelector('wallet-select-language')).click()
cy.get(getTestSelector('wallet-language-item')).contains('Afrikaans').click({ force: true })
cy.get(getTestSelector('wallet-header')).should('contain', 'Taal')
cy.get(getTestSelector('wallet-language-item')).contains('English').click({ force: true })
cy.get(getTestSelector('wallet-header')).should('contain', 'Language')
cy.get(getTestSelector('wallet-back')).click()
})
it('should be able to view transactions', () => {
cy.get(getTestSelector('wallet-transactions')).click()
cy.get(getTestSelector('wallet-empty-transaction-text')).should('exist')
cy.get(getTestSelector('wallet-back')).click()
})
it('should change the theme when not connected', () => {
cy.get(getTestSelector('wallet-disconnect')).click()
cy.get(getTestSelector('wallet-select-theme')).click()
cy.get(getTestSelector('wallet-select-theme')).contains('Dark theme').should('exist')
})
it('should select a language when not connected', () => {
cy.get(getTestSelector('wallet-select-language')).click()
cy.get(getTestSelector('wallet-language-item')).contains('Afrikaans').click({ force: true })
cy.get(getTestSelector('wallet-header')).should('contain', 'Taal')
cy.get(getTestSelector('wallet-language-item')).contains('English').click({ force: true })
cy.get(getTestSelector('wallet-header')).should('contain', 'Language')
cy.get(getTestSelector('wallet-back')).click()
})
it('should open the wallet connect modal from the drop down when not connected', () => {
cy.get(getTestSelector('wallet-connect-wallet')).click()
cy.get(getTestSelector('wallet-modal')).should('exist')
cy.get(getTestSelector('wallet-modal-close')).click()
})
})

View File

@@ -9,6 +9,8 @@
import { injected } from './ethereum'
import assert = require('assert')
import { FeatureFlag } from '../../src/featureFlags/flags/featureFlags'
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
@@ -17,6 +19,7 @@ declare global {
}
interface VisitOptions {
serviceWorker?: true
featureFlags?: Array<FeatureFlag>
}
}
}
@@ -36,6 +39,18 @@ Cypress.Commands.overwrite(
options?.onBeforeLoad?.(win)
win.localStorage.clear()
win.localStorage.setItem('redux_localstorage_simple_user', '{"selectedWallet":"INJECTED"}')
if (options?.featureFlags) {
const featureFlags = options.featureFlags.reduce(
(flags, flag) => ({
...flags,
[flag]: 'enabled',
}),
{}
)
win.localStorage.setItem('featureFlags', JSON.stringify(featureFlags))
}
win.ethereum = injected
},
})

View File

@@ -3,6 +3,7 @@
*/
import { Eip1193Bridge } from '@ethersproject/experimental/lib/eip1193-bridge'
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { JsonRpcProvider } from '@ethersproject/providers'
import { Wallet } from '@ethersproject/wallet'

1
cypress/utils/index.ts Normal file
View File

@@ -0,0 +1 @@
export const getTestSelector = (selectorId: string) => `[data-testid=${selectorId}]`

View File

@@ -137,7 +137,7 @@
"@uniswap/redux-multicall": "^1.1.5",
"@uniswap/router-sdk": "^1.3.0",
"@uniswap/sdk-core": "^3.0.1",
"@uniswap/smart-order-router": "^2.9.2",
"@uniswap/smart-order-router": "^2.10.0",
"@uniswap/token-lists": "^1.0.0-beta.30",
"@uniswap/v2-core": "1.0.0",
"@uniswap/v2-periphery": "^1.1.0-beta.0",
@@ -145,7 +145,7 @@
"@uniswap/v3-core": "1.0.0",
"@uniswap/v3-periphery": "^1.1.1",
"@uniswap/v3-sdk": "^3.9.0",
"@uniswap/widgets": "^2.3.1",
"@uniswap/widgets": "^2.8.1",
"@vanilla-extract/css": "^1.7.2",
"@vanilla-extract/css-utils": "^0.1.2",
"@vanilla-extract/dynamic": "^2.0.2",

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

3
src/assets/svg/socks.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="10" height="12" viewBox="0 0 10 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.75 11C4.75 11 5.22377 11.1391 5.80923 10.5828L7.64433 8.65908C8.35405 7.91508 8.74998 6.92678 8.74989 5.89856C8.7498 4.72716 8.74971 3.31706 8.74991 2.50009C8.74996 2.22391 8.77618 2 8.5 2H8.25M6.74898 5.75L6.74979 2L6.74991 1.50009C6.74996 1.22391 6.52609 1 6.24991 1H4.25167C3.97553 1 3.75167 1.22386 3.75167 1.5V4.75039C3.75167 5.29859 3.52665 5.82276 3.12922 6.20034L1.6891 7.56856C1.10364 8.12478 1.10363 9.0266 1.68909 9.58283C2.12197 9.99409 2.75372 10.1013 3.29025 9.90438C3.47937 9.83497 3.65665 9.72779 3.80923 9.58283L5.80923 7.6827M6.74898 5.75L6.7487 6.36119C6.74861 6.63517 6.63611 6.89711 6.43748 7.08582L5.80923 7.6827M6.74898 5.75H6.4384C5.67845 5.75 5.19623 6.56419 5.56146 7.23061L5.80923 7.6827" stroke="white" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 871 B

View File

@@ -145,7 +145,7 @@ const CloseIcon = styled.div`
top: 14px;
&:hover {
cursor: pointer;
opacity: 0.6;
opacity: ${({ theme }) => theme.opacity.hover};
}
`

View File

@@ -15,7 +15,8 @@ export enum EventName {
SWAP_MAX_TOKEN_AMOUNT_SELECTED = 'Swap Max Token Amount Selected',
SWAP_PRICE_UPDATE_ACKNOWLEDGED = 'Swap Price Update Acknowledged',
SWAP_QUOTE_RECEIVED = 'Swap Quote Received',
SWAP_SUBMITTED = 'Swap Submitted',
SWAP_SIGNED = 'Swap Signed',
SWAP_SUBMITTED_BUTTON_CLICKED = 'Swap Submit Button Clicked',
SWAP_TOKENS_REVERSED = 'Swap Tokens Reversed',
SWAP_TRANSACTION_COMPLETED = 'Swap Transaction Completed',
TOKEN_IMPORTED = 'Token Imported',

View File

@@ -15,7 +15,6 @@ export function initializeAnalytics() {
console.error(`${keyName} is undefined, Amplitude analytics will not run.`)
return
}
init(
API_KEY,
/* userId= */ undefined, // User ID should be undefined to let Amplitude default to Device ID
@@ -23,7 +22,8 @@ export function initializeAnalytics() {
{
// Disable tracking of private user information by Amplitude
trackingOptions: {
ipAddress: false,
// IP is being dropped before ingestion on Amplitude side, only being used to determine country.
ipAddress: isProductionEnv() ? false : true,
carrier: false,
city: false,
region: false,

View File

@@ -1,49 +1,45 @@
import { curveCardinalOpen, scaleLinear } from 'd3'
import { curveCardinal, scaleLinear } from 'd3'
import { SingleTokenData, TimePeriod, useTokenPricesFromFragment } from 'graphql/data/Token'
import React from 'react'
import { useTheme } from 'styled-components/macro'
import data from './data.json'
import { DATA_EMPTY, getPriceBounds } from '../Tokens/TokenDetails/PriceChart'
import LineChart from './LineChart'
type PricePoint = { value: number; timestamp: number }
function getPriceBounds(pricePoints: PricePoint[]): [number, number] {
const prices = pricePoints.map((x) => x.value)
const min = Math.min(...prices)
const max = Math.max(...prices)
return [min, max]
}
interface SparklineChartProps {
width: number
height: number
tokenData: SingleTokenData
pricePercentChange: number | undefined | null
timePeriod: TimePeriod
}
function SparklineChart({ width, height }: SparklineChartProps) {
function SparklineChart({ width, height, tokenData, pricePercentChange, timePeriod }: SparklineChartProps) {
const theme = useTheme()
// for sparkline
const pricePoints = useTokenPricesFromFragment(tokenData?.prices?.[0]) ?? []
const hasData = pricePoints.length !== 0
const startingPrice = hasData ? pricePoints[0] : DATA_EMPTY
const endingPrice = hasData ? pricePoints[pricePoints.length - 1] : DATA_EMPTY
const widthScale = scaleLinear().domain([startingPrice.timestamp, endingPrice.timestamp]).range([0, 124])
const rdScale = scaleLinear().domain(getPriceBounds(pricePoints)).range([42, 0])
/* TODO: Implement API calls & cache to use here */
const pricePoints = data.day
const startingPrice = pricePoints[0]
const endingPrice = pricePoints[pricePoints.length - 1]
const timeScale = scaleLinear().domain([startingPrice.timestamp, endingPrice.timestamp]).range([0, width])
const rdScale = scaleLinear().domain(getPriceBounds(pricePoints)).range([height, 0])
const isPositive = endingPrice.value >= startingPrice.value
/* Default curve doesn't look good for the ALL chart */
const curveTension = timePeriod === TimePeriod.ALL ? 0.75 : 0.9
return (
<LineChart
data={pricePoints}
getX={(p: PricePoint) => timeScale(p.timestamp)}
getX={(p: PricePoint) => widthScale(p.timestamp)}
getY={(p: PricePoint) => rdScale(p.value)}
curve={curveCardinalOpen.tension(0.9)}
marginTop={0}
color={isPositive ? theme.accentSuccess : theme.accentFailure}
curve={curveCardinal.tension(curveTension)}
color={pricePercentChange && pricePercentChange < 0 ? theme.accentFailure : theme.accentSuccess}
strokeWidth={1.5}
width={width}
height={height}
></LineChart>
/>
)
}

View File

@@ -24,11 +24,13 @@ const StyledNativeLogo = styled(StyledLogo)`
export default function CurrencyLogo({
currency,
symbol,
size = '24px',
style,
...rest
}: {
currency?: Currency | null
symbol?: string | null
size?: string
style?: React.CSSProperties
}) {
@@ -36,6 +38,7 @@ export default function CurrencyLogo({
alt: `${currency?.symbol ?? 'token'} logo`,
size,
srcs: useCurrencyLogoURIs(currency),
symbol: symbol ?? currency?.symbol,
style,
...rest,
}

View File

@@ -5,6 +5,7 @@ import { RedesignVariant, useRedesignFlag } from 'featureFlags/flags/redesign'
import { TokensVariant, useTokensFlag } from 'featureFlags/flags/tokens'
import { TokenSafetyVariant, useTokenSafetyFlag } from 'featureFlags/flags/tokenSafety'
import { TokensNetworkFilterVariant, useTokensNetworkFilterFlag } from 'featureFlags/flags/tokensNetworkFilter'
import { TraceJsonRpcVariant, useTraceJsonRpcFlag } from 'featureFlags/flags/traceJsonRpc'
import { useAtomValue, useUpdateAtom } from 'jotai/utils'
import { Children, PropsWithChildren, ReactElement, ReactNode, useCallback, useState } from 'react'
import { X } from 'react-feather'
@@ -241,6 +242,14 @@ export default function FeatureFlagModal() {
<FeatureFlagGroup name="Phase 1">
<FeatureFlagOption variant={NftVariant} value={useNftFlag()} featureFlag={FeatureFlag.nft} label="NFTs" />
</FeatureFlagGroup>
<FeatureFlagGroup name="Debug">
<FeatureFlagOption
variant={TraceJsonRpcVariant}
value={useTraceJsonRpcFlag()}
featureFlag={FeatureFlag.traceJsonRpc}
label="Enables JSON-RPC tracing"
/>
</FeatureFlagGroup>
<SaveButton onClick={() => window.location.reload()}>Reload</SaveButton>
</Modal>
)

View File

@@ -0,0 +1,32 @@
import { ChevronDown, ChevronUp } from 'react-feather'
import styled from 'styled-components/macro'
export const StyledChevronDown = styled(ChevronDown)<{ customColor?: string }>`
color: ${({ theme, customColor }) => customColor ?? theme.textSecondary};
height: 20px;
width: 20px;
&:hover {
color: ${({ theme }) => theme.accentActionSoft};
transition: ${({
theme: {
transition: { duration, timing },
},
}) => `${duration.fast} color ${timing.in}`};
}
`
export const StyledChevronUp = styled(ChevronUp)<{ customColor?: string }>`
color: ${({ theme, customColor }) => customColor ?? theme.textSecondary};
height: 20px;
width: 20px;
&:hover {
color: ${({ theme }) => theme.accentActionSoft};
transition: ${({
theme: {
transition: { duration, timing },
},
}) => `${duration.fast} color ${timing.in}`};
}
`

View File

@@ -1,11 +1,18 @@
import { useWeb3React } from '@web3-react/core'
import { ConnectionType } from 'connection'
import { NavBarVariant, useNavBarFlag } from 'featureFlags/flags/navBar'
import useENSAvatar from 'hooks/useENSAvatar'
import styled from 'styled-components/macro'
import { colors } from 'theme/colors'
import CoinbaseWalletIcon from '../../assets/images/coinbaseWalletIcon.svg'
import WalletConnectIcon from '../../assets/images/walletConnectIcon.svg'
import sockImg from '../../assets/svg/socks.svg'
import { useHasSocks } from '../../hooks/useSocksBalance'
import Identicon from '../Identicon'
const IconWrapper = styled.div<{ size?: number }>`
position: relative;
${({ theme }) => theme.flexColumnNoWrap};
align-items: center;
justify-content: center;
@@ -20,19 +27,57 @@ const IconWrapper = styled.div<{ size?: number }>`
`};
`
export default function StatusIcon({ connectionType, size }: { connectionType: ConnectionType; size?: number }) {
let image
switch (connectionType) {
case ConnectionType.INJECTED:
image = <Identicon />
break
case ConnectionType.WALLET_CONNECT:
image = <img src={WalletConnectIcon} alt="WalletConnect" />
break
case ConnectionType.COINBASE_WALLET:
image = <img src={CoinbaseWalletIcon} alt="Coinbase Wallet" />
break
const SockContainer = styled.div`
position: absolute;
background-color: ${colors.pink400};
display: flex;
justify-content: center;
border-radius: 50%;
width: 16px;
height: 16px;
bottom: -5px;
right: -5px;
`
const SockImg = styled.img`
width: 7.5px;
height: 10px;
margin-top: 3px;
`
const Socks = () => {
return (
<SockContainer>
<SockImg src={sockImg} />
</SockContainer>
)
}
const useIcon = (connectionType: ConnectionType) => {
const { account } = useWeb3React()
const { avatar } = useENSAvatar(account ?? undefined)
const isNavbarEnabled = useNavBarFlag() === NavBarVariant.Enabled
if ((isNavbarEnabled && avatar) || connectionType === ConnectionType.INJECTED) {
return <Identicon />
} else if (connectionType === ConnectionType.WALLET_CONNECT) {
return <img src={WalletConnectIcon} alt="WalletConnect" />
} else if (connectionType === ConnectionType.COINBASE_WALLET) {
return <img src={CoinbaseWalletIcon} alt="Coinbase Wallet" />
}
return <IconWrapper size={size ?? 16}>{image}</IconWrapper>
return undefined
}
export default function StatusIcon({ connectionType, size }: { connectionType: ConnectionType; size?: number }) {
const hasSocks = useHasSocks()
const isNavbarEnabled = useNavBarFlag() === NavBarVariant.Enabled
const icon = useIcon(connectionType)
return (
<IconWrapper size={size ?? 16}>
{isNavbarEnabled && hasSocks && <Socks />}
{icon}
</IconWrapper>
)
}

View File

@@ -1,12 +1,13 @@
import jazzicon from '@metamask/jazzicon'
import { useWeb3React } from '@web3-react/core'
import { NavBarVariant, useNavBarFlag } from 'featureFlags/flags/navBar'
import useENSAvatar from 'hooks/useENSAvatar'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components/macro'
const StyledIdenticon = styled.div`
height: 1rem;
width: 1rem;
const StyledIdenticon = styled.div<{ isNavbarEnabled: boolean }>`
height: ${({ isNavbarEnabled }) => (isNavbarEnabled ? '24px' : '1rem')};
width: ${({ isNavbarEnabled }) => (isNavbarEnabled ? '24px' : '1rem')};
border-radius: 1.125rem;
background-color: ${({ theme }) => theme.deprecated_bg4};
font-size: initial;
@@ -22,8 +23,10 @@ export default function Identicon() {
const { account } = useWeb3React()
const { avatar } = useENSAvatar(account ?? undefined)
const [fetchable, setFetchable] = useState(true)
const isNavbarEnabled = useNavBarFlag() === NavBarVariant.Enabled
const iconSize = isNavbarEnabled ? 24 : 16
const icon = useMemo(() => account && jazzicon(16, parseInt(account.slice(2, 10), 16)), [account])
const icon = useMemo(() => account && jazzicon(iconSize, parseInt(account.slice(2, 10), 16)), [account, iconSize])
const iconRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const current = iconRef.current
@@ -41,7 +44,7 @@ export default function Identicon() {
}, [icon, iconRef])
return (
<StyledIdenticon>
<StyledIdenticon isNavbarEnabled={isNavbarEnabled}>
{avatar && fetchable ? (
<StyledAvatar alt="avatar" src={avatar} onError={() => setFetchable(false)}></StyledAvatar>
) : (

View File

@@ -19,7 +19,7 @@ const HandleAccent = styled.path`
stroke-width: 1.5;
stroke: ${({ theme }) => theme.deprecated_white};
opacity: 0.6;
opacity: ${({ theme }) => theme.opacity.hover};
`
const LabelGroup = styled.g<{ visible: boolean }>`

View File

@@ -14,13 +14,15 @@ export default function ListLogo({
style,
size = '24px',
alt,
symbol,
}: {
logoURI: string
size?: string
style?: React.CSSProperties
alt?: string
symbol?: string
}) {
const srcs: string[] = useHttpLocations(logoURI)
return <StyledListLogo alt={alt} size={size} srcs={srcs} style={style} />
return <StyledListLogo alt={alt} size={size} symbol={symbol} srcs={srcs} style={style} />
}

View File

@@ -1,22 +1,34 @@
import { useState } from 'react'
import { Slash } from 'react-feather'
import { ImageProps } from 'rebass'
import { useTheme } from 'styled-components/macro'
import styled from 'styled-components/macro'
const BAD_SRCS: { [tokenAddress: string]: true } = {}
interface LogoProps extends Pick<ImageProps, 'style' | 'alt' | 'className'> {
srcs: string[]
symbol?: string
size?: string
}
const MissingImageLogo = styled.div<{ size?: string }>`
--size: ${({ size }) => size};
border-radius: 100px;
color: ${({ theme }) => theme.textPrimary};
background-color: ${({ theme }) => theme.backgroundInteractive};
font-size: calc(var(--size) / 3);
font-weight: 500;
height: ${({ size }) => size ?? '24px'};
line-height: ${({ size }) => size ?? '24px'};
text-align: center;
width: ${({ size }) => size ?? '24px'};
`
/**
* Renders an image by sequentially trying a list of URIs, and then eventually a fallback triangle alert
*/
export default function Logo({ srcs, alt, style, ...rest }: LogoProps) {
export default function Logo({ srcs, alt, style, size, symbol, ...rest }: LogoProps) {
const [, refresh] = useState<number>(0)
const theme = useTheme()
const src: string | undefined = srcs.find((src) => !BAD_SRCS[src])
if (src) {
@@ -34,5 +46,10 @@ export default function Logo({ srcs, alt, style, ...rest }: LogoProps) {
)
}
return <Slash {...rest} style={{ ...style, color: theme.deprecated_bg4 }} />
return (
<MissingImageLogo size={size}>
{/* use only first 3 characters of Symbol for design reasons */}
{symbol?.toUpperCase().replace('$', '').replace(/\s+/g, '').slice(0, 3)}
</MissingImageLogo>
)
}

View File

@@ -278,7 +278,7 @@ export default function Menu() {
</ToggleMenuItem>
<ToggleMenuItem onClick={() => toggleDarkMode()}>
<div>{darkMode ? <Trans>Light Theme</Trans> : <Trans>Dark Theme</Trans>}</div>
{darkMode ? <Moon opacity={0.6} size={16} /> : <Sun opacity={0.6} size={16} />}
{darkMode ? <Sun opacity={0.6} size={16} /> : <Moon opacity={0.6} size={16} />}
</ToggleMenuItem>
<MenuItem href="https://docs.uniswap.org/">
<div>

View File

@@ -4,6 +4,7 @@ import React from 'react'
import { animated, useSpring, useTransition } from 'react-spring'
import { useGesture } from 'react-use-gesture'
import styled, { css } from 'styled-components/macro'
import { Z_INDEX } from 'theme/zIndex'
import { isMobile } from '../../utils/userAgent'
@@ -11,7 +12,7 @@ const AnimatedDialogOverlay = animated(DialogOverlay)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const StyledDialogOverlay = styled(AnimatedDialogOverlay)<{ redesignFlag?: boolean }>`
&[data-reach-dialog-overlay] {
z-index: 2;
z-index: ${Z_INDEX.modalBackdrop};
background-color: transparent;
overflow: hidden;

View File

@@ -1,4 +1,5 @@
import { useWeb3React } from '@web3-react/core'
import { StyledChevronDown, StyledChevronUp } from 'components/Icons'
import { getChainInfo } from 'constants/chainInfo'
import { SupportedChainId } from 'constants/chains'
import { useOnClickOutside } from 'hooks/useOnClickOutside'
@@ -7,7 +8,7 @@ import useSyncChainQuery from 'hooks/useSyncChainQuery'
import { Box } from 'nft/components/Box'
import { Portal } from 'nft/components/common/Portal'
import { Column, Row } from 'nft/components/Flex'
import { CheckMarkIcon, NewChevronDownIcon, NewChevronUpIcon, TokenWarningRedIcon } from 'nft/components/icons'
import { CheckMarkIcon, TokenWarningRedIcon } from 'nft/components/icons'
import { subhead } from 'nft/css/common.css'
import { themeVars, vars } from 'nft/css/sprinkles.css'
import { useIsMobile } from 'nft/hooks'
@@ -108,23 +109,19 @@ export const ChainSwitcher = ({ leftAlign }: ChainSwitcherProps) => {
{!isSupported ? (
<>
<TokenWarningRedIcon fill={themeVars.colors.darkGray} width={24} height={24} />
<Box as="span" className={subhead} display={{ sm: 'none', xl: 'flex' }} style={{ lineHeight: '20px' }}>
<Box as="span" className={subhead} display={{ sm: 'none', xxl: 'flex' }} style={{ lineHeight: '20px' }}>
Unsupported
</Box>
</>
) : (
<>
<img src={info.logoUrl} alt={info.label} className={styles.Image} />
<Box as="span" className={subhead} display={{ sm: 'none', xl: 'flex' }} style={{ lineHeight: '20px' }}>
<Box as="span" className={subhead} display={{ sm: 'none', xxl: 'flex' }} style={{ lineHeight: '20px' }}>
{info.label}
</Box>
</>
)}
{isOpen ? (
<NewChevronUpIcon width={16} height={16} color="blackBlue" />
) : (
<NewChevronDownIcon width={16} height={16} color="blackBlue" />
)}
{isOpen ? <StyledChevronUp /> : <StyledChevronDown />}
</Row>
{isOpen && (isMobile ? <Portal>{dropdown}</Portal> : <>{dropdown}</>)}
</Box>

View File

@@ -41,6 +41,7 @@ export const SecondaryText = style([
paddingY: '8',
paddingX: '8',
color: 'darkGray',
width: 'full',
}),
{
lineHeight: '20px',

View File

@@ -126,15 +126,15 @@ export const MenuDropdown = () => {
<>
<Box position="relative" ref={ref}>
<NavIcon isActive={isOpen} onClick={toggleOpen}>
<EllipsisIcon width={28} height={28} />
<EllipsisIcon width={20} height={20} />
</NavIcon>
{isOpen && (
<NavDropdown top={{ sm: 'unset', xxl: '56' }} bottom={{ sm: '56', xxl: 'unset' }} right="0">
<NavDropdown top={{ sm: 'unset', lg: '56' }} bottom={{ sm: '56', lg: 'unset' }} right="0">
<Column gap="16">
<Column paddingX="8" gap="4">
{nftFlag === NftVariant.Enabled && (
<PrimaryMenuRow to="/nft/sell" close={toggleOpen}>
<PrimaryMenuRow to="/nfts/sell" close={toggleOpen}>
<Icon>
<ThinTagIcon width={24} height={24} />
</Icon>

View File

@@ -38,4 +38,8 @@ export const mobileNavDropdown = style([
right: '0',
width: 'full',
}),
{
borderRightWidth: '0px',
borderLeftWidth: '0px',
},
])

View File

@@ -11,7 +11,7 @@ export const navIcon = style([
justifyContent: 'center',
textAlign: 'center',
cursor: 'pointer',
padding: '8',
padding: '10',
borderRadius: '8',
transition: '250',
}),
@@ -19,6 +19,6 @@ export const navIcon = style([
':hover': {
background: vars.color.lightGrayOverlay,
},
zIndex: 2,
zIndex: 1,
},
])

View File

@@ -61,7 +61,7 @@ export const middleContainer = style([
flex: '1',
flexShrink: '1',
justifyContent: 'center',
display: { sm: 'none', lg: 'flex' },
display: { sm: 'none', xl: 'flex' },
}),
])
@@ -81,6 +81,8 @@ const baseMenuItem = style([
borderRadius: '12',
transition: '250',
height: 'min',
width: 'full',
textAlign: 'center',
}),
{
lineHeight: '24px',
@@ -109,7 +111,7 @@ export const activeMenuItem = style([
export const mobileBottomBar = style([
sprinkles({
position: 'fixed',
display: { sm: 'flex', xxl: 'none' },
display: { sm: 'flex', lg: 'none' },
bottom: '0',
right: '0',
left: '0',

View File

@@ -1,17 +1,17 @@
import { Trans } from '@lingui/macro'
import { ChainSwitcher } from 'components/NavBar/ChainSwitcher'
import { MenuDropdown } from 'components/NavBar/MenuDropdown'
import * as styles from 'components/NavBar/Navbar.css'
import { SearchBar } from 'components/NavBar/SearchBar'
import { ShoppingBag } from 'components/NavBar/ShoppingBag'
import Web3Status from 'components/Web3Status'
import { NftVariant, useNftFlag } from 'featureFlags/flags/nft'
import { Box } from 'nft/components/Box'
import { Row } from 'nft/components/Flex'
import { UniIcon } from 'nft/components/icons'
import { ReactNode } from 'react'
import { NavLink, NavLinkProps, useLocation } from 'react-router-dom'
import { Box } from '../../nft/components/Box'
import { Row } from '../../nft/components/Flex'
import { UniIcon } from '../../nft/components/icons'
import { ChainSwitcher } from './ChainSwitcher'
import { MenuDropdown } from './MenuDropdown'
import * as styles from './Navbar.css'
import { SearchBar } from './SearchBar'
interface MenuItemProps {
href: string
id?: NavLinkProps['id']
@@ -64,6 +64,9 @@ const PageTabs = () => {
}
const Navbar = () => {
const { pathname } = useLocation()
const isNftPage = pathname.startsWith('/nfts')
return (
<>
<nav className={styles.nav}>
@@ -72,10 +75,10 @@ const Navbar = () => {
<Box as="a" href="#/swap" className={styles.logoContainer}>
<UniIcon width="48" height="48" className={styles.logo} />
</Box>
<Box display={{ sm: 'flex', xxl: 'none' }}>
<Box display={{ sm: 'flex', lg: 'none' }}>
<ChainSwitcher leftAlign={true} />
</Box>
<Row gap="8" display={{ sm: 'none', xxl: 'flex' }}>
<Row gap="8" display={{ sm: 'none', lg: 'flex' }}>
<PageTabs />
</Row>
</Box>
@@ -84,13 +87,14 @@ const Navbar = () => {
</Box>
<Box className={styles.rightSideContainer}>
<Row gap="12">
<Box display={{ sm: 'flex', lg: 'none' }}>
<Box display={{ sm: 'flex', xl: 'none' }}>
<SearchBar />
</Box>
<Box display={{ sm: 'none', xxl: 'flex' }}>
<Box display={{ sm: 'none', lg: 'flex' }}>
<MenuDropdown />
</Box>
<Box display={{ sm: 'none', xxl: 'flex' }}>
{isNftPage && <ShoppingBag />}
<Box display={{ sm: 'none', lg: 'flex' }}>
<ChainSwitcher />
</Box>
@@ -101,7 +105,9 @@ const Navbar = () => {
</nav>
<Box className={styles.mobileBottomBar}>
<PageTabs />
<MenuDropdown />
<Box marginY="4">
<MenuDropdown />
</Box>
</Box>
</>
)

View File

@@ -3,7 +3,8 @@ import { buttonTextSmall, subhead, subheadSmall } from 'nft/css/common.css'
import { breakpoints, sprinkles, vars } from '../../nft/css/sprinkles.css'
const DESKTOP_NAVBAR_WIDTH = '360px'
const DESKTOP_NAVBAR_WIDTH = 360
const MAGNIFYING_GLASS_ICON_WIDTH = 28
const baseSearchStyle = style([
sprinkles({
@@ -15,8 +16,24 @@ const baseSearchStyle = style([
}),
{
'@media': {
[`screen and (min-width: ${breakpoints.md}px)`]: {
width: DESKTOP_NAVBAR_WIDTH,
[`screen and (min-width: ${breakpoints.sm}px)`]: {
width: `${DESKTOP_NAVBAR_WIDTH}px`,
},
},
},
])
export const searchBarContainer = style([
sprinkles({
right: '0',
top: '0',
zIndex: '3',
display: 'inline-block',
}),
{
'@media': {
[`screen and (min-width: ${breakpoints.lg}px)`]: {
right: `-${DESKTOP_NAVBAR_WIDTH / 2 - MAGNIFYING_GLASS_ICON_WIDTH}px`,
},
},
},
@@ -25,7 +42,6 @@ const baseSearchStyle = style([
export const searchBar = style([
baseSearchStyle,
sprinkles({
height: 'full',
color: 'placeholder',
paddingX: '16',
cursor: 'pointer',
@@ -42,18 +58,18 @@ export const searchBarInput = style([
border: 'none',
background: 'none',
}),
{ lineHeight: '24px' },
{
lineHeight: '24px',
},
])
export const searchBarDropdown = style([
baseSearchStyle,
sprinkles({
position: 'absolute',
left: '0',
top: '48',
borderBottomLeftRadius: '12',
borderBottomRightRadius: '12',
background: 'lightGray',
height: { sm: 'viewHeight', md: 'auto' },
}),
{
borderTop: 'none',
@@ -68,7 +84,6 @@ export const suggestionRow = style([
justifyContent: 'space-between',
paddingY: '8',
paddingX: '16',
transition: '250',
}),
{
':hover': {
@@ -151,3 +166,50 @@ export const notFoundContainer = style([
paddingLeft: '16',
}),
])
const visibilityTransition = `visibility ${vars.time[125]}, opacity ${vars.time[125]}`
const delayedTransitionProperties = `padding 0s ${vars.time[125]}, height 0s ${vars.time[125]}`
export const hidden = style([
sprinkles({
visibility: 'hidden',
opacity: '0',
padding: '0',
height: '0',
}),
{
transition: `${visibilityTransition}, ${delayedTransitionProperties}`,
transitionTimingFunction: 'ease-in',
},
])
export const visible = style([
sprinkles({
visibility: 'visible',
opacity: '1',
height: 'full',
}),
{
transition: `${visibilityTransition}`,
transitionTimingFunction: 'ease-out',
},
])
export const searchContentCentered = style({
'@media': {
[`screen and (min-width: ${breakpoints.lg}px)`]: {
transform: `translateX(${DESKTOP_NAVBAR_WIDTH / 4}px)`,
transition: `transform ${vars.time[125]}`,
transitionTimingFunction: 'ease-out',
},
},
})
export const searchContentLeftAlign = style({
'@media': {
[`screen and (min-width: ${breakpoints.lg}px)`]: {
transform: 'translateX(0)',
transition: `transform ${vars.time[125]}`,
transitionTimingFunction: 'ease-in',
},
},
})

View File

@@ -9,13 +9,13 @@ import { Box } from 'nft/components/Box'
import { Column, Row } from 'nft/components/Flex'
import { Overlay } from 'nft/components/modals/Overlay'
import { magicalGradientOnHover, subheadSmall } from 'nft/css/common.css'
import { useIsMobile, useSearchHistory } from 'nft/hooks'
import { useIsMobile, useIsTablet, useSearchHistory } from 'nft/hooks'
import { fetchSearchCollections, fetchTrendingCollections } from 'nft/queries'
import { fetchSearchTokens } from 'nft/queries/genie/SearchTokensFetcher'
import { fetchTrendingTokens } from 'nft/queries/genie/TrendingTokensFetcher'
import { FungibleToken, GenieCollection, TimePeriod, TrendingCollection } from 'nft/types'
import { formatEthPrice } from 'nft/utils/currency'
import { ChangeEvent, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { ChangeEvent, ReactNode, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { useQuery } from 'react-query'
import { useLocation } from 'react-router-dom'
@@ -38,6 +38,7 @@ interface SearchBarDropdownSectionProps {
hoveredIndex: number | undefined
startingIndex: number
setHoveredIndex: (index: number | undefined) => void
isLoading?: boolean
}
export const SearchBarDropdownSection = ({
@@ -48,6 +49,7 @@ export const SearchBarDropdownSection = ({
hoveredIndex,
startingIndex,
setHoveredIndex,
isLoading,
}: SearchBarDropdownSectionProps) => {
return (
<Column gap="12">
@@ -56,8 +58,10 @@ export const SearchBarDropdownSection = ({
<Box>{header}</Box>
</Row>
<Column gap="12">
{suggestions?.map((suggestion, index) =>
isCollection(suggestion) ? (
{suggestions.map((suggestion, index) =>
isLoading ? (
<SkeletonRow key={index} />
) : isCollection(suggestion) ? (
<CollectionRow
key={suggestion.address}
collection={suggestion as GenieCollection}
@@ -87,17 +91,18 @@ interface SearchBarDropdownProps {
tokens: FungibleToken[]
collections: GenieCollection[]
hasInput: boolean
isLoading: boolean
}
export const SearchBarDropdown = ({ toggleOpen, tokens, collections, hasInput }: SearchBarDropdownProps) => {
const [hoveredIndex, setHoveredIndex] = useState<number | undefined>(undefined)
const searchHistory = useSearchHistory(
(state: { history: (FungibleToken | GenieCollection)[] }) => state.history
).slice(0, 2)
export const SearchBarDropdown = ({ toggleOpen, tokens, collections, hasInput, isLoading }: SearchBarDropdownProps) => {
const [hoveredIndex, setHoveredIndex] = useState<number | undefined>(0)
const searchHistory = useSearchHistory((state: { history: (FungibleToken | GenieCollection)[] }) => state.history)
const shortenedHistory = useMemo(() => searchHistory.slice(0, 2), [searchHistory])
const { pathname } = useLocation()
const isNFTPage = pathname.includes('/nfts')
const isTokenPage = pathname.includes('/tokens')
const phase1Flag = useNftFlag()
const [resultsState, setResultsState] = useState<ReactNode>()
const tokenSearchResults =
tokens.length > 0 ? (
@@ -131,50 +136,56 @@ export const SearchBarDropdown = ({ toggleOpen, tokens, collections, hasInput }:
)
) : null
const { data: trendingCollectionResults } = useQuery(['trendingCollections', 'eth', 'twenty_four_hours'], () =>
fetchTrendingCollections({ volumeType: 'eth', timePeriod: 'ONE_DAY' as TimePeriod, size: 3 })
const { data: trendingCollectionResults, isLoading: trendingCollectionsAreLoading } = useQuery(
['trendingCollections', 'eth', 'twenty_four_hours'],
() => fetchTrendingCollections({ volumeType: 'eth', timePeriod: 'ONE_DAY' as TimePeriod, size: 3 })
)
const trendingCollections = useMemo(() => {
return trendingCollectionResults
?.map((collection) => {
return {
...collection,
collectionAddress: collection.address,
floorPrice: formatEthPrice(collection.floor?.toString()),
stats: {
total_supply: collection.totalSupply,
one_day_change: collection.floorChange,
},
}
})
.slice(0, isNFTPage ? 3 : 2)
}, [isNFTPage, trendingCollectionResults])
const showTrendingCollections: boolean = useMemo(
() => (trendingCollections?.length ?? 0) > 0 && !isTokenPage && phase1Flag === NftVariant.Enabled,
[trendingCollections?.length, isTokenPage, phase1Flag]
const trendingCollections = useMemo(
() =>
trendingCollectionResults
? trendingCollectionResults
.map((collection) => ({
...collection,
collectionAddress: collection.address,
floorPrice: formatEthPrice(collection.floor?.toString()),
stats: {
total_supply: collection.totalSupply,
one_day_change: collection.floorChange,
},
}))
.slice(0, isNFTPage ? 3 : 2)
: [...Array<GenieCollection>(isNFTPage ? 3 : 2)],
[isNFTPage, trendingCollectionResults]
)
const { data: trendingTokenResults } = useQuery([], () => fetchTrendingTokens(4), {
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
})
const { data: trendingTokenResults, isLoading: trendingTokensAreLoading } = useQuery(
['trendingTokens'],
() => fetchTrendingTokens(4),
{
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
}
)
const trendingTokensLength = phase1Flag === NftVariant.Enabled ? (isTokenPage ? 3 : 2) : 4
const trendingTokens = useMemo(() => {
return trendingTokenResults?.slice(0, trendingTokensLength)
}, [trendingTokenResults, trendingTokensLength])
const trendingTokens = useMemo(
() =>
trendingTokenResults
? trendingTokenResults.slice(0, trendingTokensLength)
: [...Array<FungibleToken>(trendingTokensLength)],
[trendingTokenResults, trendingTokensLength]
)
const totalSuggestions = hasInput
? tokens.length + collections.length
: Math.min(searchHistory.length, 2) +
: Math.min(shortenedHistory.length, 2) +
(isNFTPage || !isTokenPage ? trendingCollections?.length ?? 0 : 0) +
(isTokenPage || !isNFTPage ? trendingTokens?.length ?? 0 : 0)
// Close the modal on escape
// Navigate search results via arrow keys
useEffect(() => {
const keyDownHandler = (event: KeyboardEvent) => {
if (event.key === 'ArrowUp') {
@@ -185,6 +196,7 @@ export const SearchBarDropdown = ({ toggleOpen, tokens, collections, hasInput }:
setHoveredIndex(hoveredIndex - 1)
}
} else if (event.key === 'ArrowDown') {
event.preventDefault()
if (hoveredIndex && hoveredIndex === totalSuggestions - 1) {
setHoveredIndex(0)
} else {
@@ -200,61 +212,90 @@ export const SearchBarDropdown = ({ toggleOpen, tokens, collections, hasInput }:
}
}, [toggleOpen, hoveredIndex, totalSuggestions])
useEffect(() => {
if (!isLoading) {
const currentState = () =>
hasInput ? (
// Empty or Up to 8 combined tokens and nfts
<Column gap="20">
{isNFTPage ? (
<>
{collectionSearchResults}
{tokenSearchResults}
</>
) : (
<>
{tokenSearchResults}
{collectionSearchResults}
</>
)}
</Column>
) : (
// Recent Searches, Trending Tokens, Trending Collections
<Column gap="20">
{shortenedHistory.length > 0 && (
<SearchBarDropdownSection
hoveredIndex={hoveredIndex}
startingIndex={0}
setHoveredIndex={setHoveredIndex}
toggleOpen={toggleOpen}
suggestions={shortenedHistory}
header={<Trans>Recent searches</Trans>}
headerIcon={<ClockIcon />}
/>
)}
{!isNFTPage && (
<SearchBarDropdownSection
hoveredIndex={hoveredIndex}
startingIndex={shortenedHistory.length}
setHoveredIndex={setHoveredIndex}
toggleOpen={toggleOpen}
suggestions={trendingTokens}
header={<Trans>Popular tokens</Trans>}
headerIcon={<TrendingArrow />}
isLoading={trendingTokensAreLoading}
/>
)}
{!isTokenPage && phase1Flag === NftVariant.Enabled && (
<SearchBarDropdownSection
hoveredIndex={hoveredIndex}
startingIndex={shortenedHistory.length + (isNFTPage ? 0 : trendingTokens?.length ?? 0)}
setHoveredIndex={setHoveredIndex}
toggleOpen={toggleOpen}
suggestions={trendingCollections as unknown as GenieCollection[]}
header={<Trans>Popular NFT collections</Trans>}
headerIcon={<TrendingArrow />}
isLoading={trendingCollectionsAreLoading}
/>
)}
</Column>
)
setResultsState(currentState)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isLoading,
tokens,
collections,
trendingCollections,
trendingCollectionsAreLoading,
trendingTokens,
trendingTokensAreLoading,
hoveredIndex,
phase1Flag,
toggleOpen,
shortenedHistory,
hasInput,
isNFTPage,
isTokenPage,
])
return (
<Box className={styles.searchBarDropdown}>
{hasInput ? (
// Empty or Up to 8 combined tokens and nfts
<Column gap="20">
{isNFTPage ? (
<>
{collectionSearchResults}
{tokenSearchResults}
</>
) : (
<>
{tokenSearchResults}
{collectionSearchResults}
</>
)}
</Column>
) : (
// Recent Searches, Trending Tokens, Trending Collections
<Column gap="20">
{searchHistory.length > 0 && (
<SearchBarDropdownSection
hoveredIndex={hoveredIndex}
startingIndex={0}
setHoveredIndex={setHoveredIndex}
toggleOpen={toggleOpen}
suggestions={searchHistory}
header={<Trans>Recent searches</Trans>}
headerIcon={<ClockIcon />}
/>
)}
{(trendingTokens?.length ?? 0) > 0 && !isNFTPage && (
<SearchBarDropdownSection
hoveredIndex={hoveredIndex}
startingIndex={searchHistory.length}
setHoveredIndex={setHoveredIndex}
toggleOpen={toggleOpen}
suggestions={trendingTokens ?? []}
header={<Trans>Popular tokens</Trans>}
headerIcon={<TrendingArrow />}
/>
)}
{showTrendingCollections && (
<SearchBarDropdownSection
hoveredIndex={hoveredIndex}
startingIndex={searchHistory.length + (isNFTPage ? 0 : trendingTokens?.length ?? 0)}
setHoveredIndex={setHoveredIndex}
toggleOpen={toggleOpen}
suggestions={trendingCollections as unknown as GenieCollection[]}
header={<Trans>Popular NFT collections</Trans>}
headerIcon={<TrendingArrow />}
/>
)}
</Column>
)}
<Box opacity={isLoading ? '0.3' : '1'} transition="125">
{resultsState}
</Box>
</Box>
)
}
@@ -268,9 +309,11 @@ export const SearchBar = () => {
const [searchValue, setSearchValue] = useState('')
const debouncedSearchValue = useDebounce(searchValue, 300)
const searchRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const { pathname } = useLocation()
const phase1Flag = useNftFlag()
const isMobile = useIsMobile()
const isTablet = useIsTablet()
useOnClickOutside(searchRef, () => {
isOpen && toggleOpen()
@@ -300,6 +343,7 @@ export const SearchBar = () => {
const [reducedTokens, reducedCollections] = organizeSearchResults(isNFTPage, tokens ?? [], collections ?? [])
// close dropdown on escape
useEffect(() => {
const escapeKeyDownHandler = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
@@ -320,65 +364,78 @@ export const SearchBar = () => {
setSearchValue('')
}, [pathname])
// auto set cursor when searchbar is opened
useEffect(() => {
if (isOpen) {
inputRef.current?.focus()
}
}, [isOpen])
const placeholderText = phase1Flag === NftVariant.Enabled ? t`Search tokens and NFT collections` : t`Search tokens`
const isMobileOrTablet = isMobile || isTablet
const showCenteredSearchContent = !isOpen && phase1Flag !== NftVariant.Enabled && !isMobileOrTablet
return (
<>
<Box position="relative">
<Box
position={{ sm: isOpen ? 'absolute' : 'relative', lg: 'relative' }}
top={{ sm: '0', lg: 'unset' }}
left={{ sm: '0', lg: 'unset' }}
width={{ sm: isOpen ? 'viewWidth' : 'auto', lg: 'auto' }}
position={{ sm: 'fixed', md: 'absolute' }}
width={{ sm: isOpen ? 'viewWidth' : 'auto', md: 'auto' }}
ref={searchRef}
style={{ zIndex: '1000' }}
className={styles.searchBarContainer}
display={{ sm: isOpen ? 'inline-block' : 'none', xl: 'inline-block' }}
>
<Row
className={clsx(`${styles.searchBar} ${!isOpen && magicalGradientOnHover}`)}
borderRadius={isOpen ? undefined : '12'}
className={clsx(
` ${styles.searchBar} ${!isOpen && !isMobile && magicalGradientOnHover} ${
isMobileOrTablet && (isOpen ? styles.visible : styles.hidden)
}`
)}
borderRadius={isOpen || isMobileOrTablet ? undefined : '12'}
borderTopRightRadius={isOpen && !isMobile ? '12' : undefined}
borderTopLeftRadius={isOpen && !isMobile ? '12' : undefined}
display={{ sm: isOpen ? 'flex' : 'none', lg: 'flex' }}
justifyContent={isOpen || phase1Flag === NftVariant.Enabled ? 'flex-start' : 'center'}
onFocus={() => !isOpen && toggleOpen()}
borderBottomWidth={isOpen || isMobileOrTablet ? '0px' : '1px'}
onClick={() => !isOpen && toggleOpen()}
gap="12"
>
<Box display={{ sm: 'none', lg: 'flex' }}>
<MagnifyingGlassIcon />
</Box>
<Box display={{ sm: 'flex', lg: 'none' }} color="placeholder" onClick={toggleOpen}>
<ChevronLeftIcon />
<Box className={showCenteredSearchContent ? styles.searchContentCentered : styles.searchContentLeftAlign}>
<Box display={{ sm: 'none', md: 'flex' }}>
<MagnifyingGlassIcon />
</Box>
<Box display={{ sm: 'flex', md: 'none' }} color="placeholder" onClick={toggleOpen}>
<ChevronLeftIcon />
</Box>
</Box>
<Box
as="input"
placeholder={placeholderText}
width={isOpen || phase1Flag === NftVariant.Enabled ? 'full' : '120'}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
!isOpen && toggleOpen()
setSearchValue(event.target.value)
}}
className={styles.searchBarInput}
className={`${styles.searchBarInput} ${
showCenteredSearchContent ? styles.searchContentCentered : styles.searchContentLeftAlign
}`}
value={searchValue}
ref={inputRef}
width={phase1Flag === NftVariant.Enabled || isOpen ? 'full' : '160'}
/>
</Row>
<Box display={{ sm: isOpen ? 'none' : 'flex', lg: 'none' }}>
<NavIcon onClick={toggleOpen}>
<NavMagnifyingGlassIcon width={28} height={28} />
</NavIcon>
</Box>
{isOpen &&
(debouncedSearchValue.length > 0 && (tokensAreLoading || collectionsAreLoading) ? (
<SkeletonRow />
) : (
<Box className={clsx(isOpen ? styles.visible : styles.hidden)}>
{isOpen && (
<SearchBarDropdown
toggleOpen={toggleOpen}
tokens={reducedTokens}
collections={reducedCollections}
hasInput={debouncedSearchValue.length > 0}
isLoading={tokensAreLoading || (collectionsAreLoading && phase1Flag === NftVariant.Enabled)}
/>
))}
)}
</Box>
</Box>
<NavIcon onClick={toggleOpen}>
<NavMagnifyingGlassIcon width={28} height={28} />
</NavIcon>
{isOpen && <Overlay />}
</>
</Box>
)
}

View File

@@ -0,0 +1,22 @@
import { style } from '@vanilla-extract/css'
import { sprinkles } from 'nft/css/sprinkles.css'
export const bagQuantity = style([
sprinkles({
position: 'absolute',
top: '4',
right: '4',
backgroundColor: 'magicGradient',
borderRadius: 'round',
color: 'explicitWhite',
textAlign: 'center',
fontWeight: 'semibold',
paddingY: '1',
paddingX: '4',
}),
{
fontSize: '8px',
lineHeight: '12px',
minWidth: '14px',
},
])

View File

@@ -0,0 +1,47 @@
import { NavIcon } from 'components/NavBar/NavIcon'
import * as styles from 'components/NavBar/ShoppingBag.css'
import { Box } from 'nft/components/Box'
import { BagIcon, HundredsOverflowIcon, TagIcon } from 'nft/components/icons'
import { useBag, useSellAsset } from 'nft/hooks'
import { useEffect, useState } from 'react'
import { useLocation } from 'react-router-dom'
export const ShoppingBag = () => {
const itemsInBag = useBag((state) => state.itemsInBag)
const sellAssets = useSellAsset((state) => state.sellAssets)
const [bagQuantity, setBagQuantity] = useState(0)
const [sellQuantity, setSellQuantity] = useState(0)
const location = useLocation()
const toggleBag = useBag((s) => s.toggleBag)
useEffect(() => {
setBagQuantity(itemsInBag.length)
}, [itemsInBag])
useEffect(() => {
setSellQuantity(sellAssets.length)
}, [sellAssets])
const isSell = location.pathname === '/nfts/sell'
return (
<NavIcon onClick={toggleBag}>
{isSell ? (
<>
<TagIcon width={20} height={20} />
{sellQuantity ? (
<Box className={styles.bagQuantity}>{sellQuantity > 99 ? <HundredsOverflowIcon /> : sellQuantity}</Box>
) : null}
</>
) : (
<>
<BagIcon width={20} height={20} />
{bagQuantity ? (
<Box className={styles.bagQuantity}>{bagQuantity > 99 ? <HundredsOverflowIcon /> : bagQuantity}</Box>
) : null}
</>
)}
</NavIcon>
)
}

View File

@@ -10,7 +10,7 @@ import { putCommas } from 'nft/utils/putCommas'
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { TokenWarningRedIcon, VerifiedIcon } from '../../nft/components/icons'
import { VerifiedIcon } from '../../nft/components/icons'
import * as styles from './SearchBar.css'
interface CollectionRowProps {
@@ -151,11 +151,7 @@ export const TokenRow = ({ token, isHovered, setHoveredIndex, toggleOpen, index
<Column className={styles.suggestionPrimaryContainer}>
<Row gap="4" width="full">
<Box className={styles.primaryText}>{token.name}</Box>
{token.onDefaultList ? (
<VerifiedIcon className={styles.suggestionIcon} />
) : (
<TokenWarningRedIcon className={styles.suggestionIcon} />
)}
{token.onDefaultList && <VerifiedIcon className={styles.suggestionIcon} />}
</Row>
<Box className={styles.secondaryText}>{token.symbol}</Box>
</Column>
@@ -179,13 +175,21 @@ export const TokenRow = ({ token, isHovered, setHoveredIndex, toggleOpen, index
export const SkeletonRow = () => {
return (
<Box className={styles.searchBarDropdown}>
<Row className={styles.suggestionRow}>
<Row>
<Box className={styles.imageHolder} />
<Box borderRadius="round" height="16" width="160" background="loading" />
</Row>
<Row className={styles.suggestionRow}>
<Row width="full">
<Box className={styles.imageHolder} />
<Column gap="4" width="full">
<Row justifyContent="space-between">
<Box borderRadius="round" height="20" background="loading" style={{ width: '180px' }} />
<Box borderRadius="round" height="20" width="48" background="loading" />
</Row>
<Row justifyContent="space-between">
<Box borderRadius="round" height="16" width="120" background="loading" />
<Box borderRadius="round" height="16" width="48" background="loading" />
</Row>
</Column>
</Row>
</Box>
</Row>
)
}

View File

@@ -4,9 +4,10 @@ import useInterval from 'lib/hooks/useInterval'
import React, { useCallback, useMemo, useState } from 'react'
import { usePopper } from 'react-popper'
import styled from 'styled-components/macro'
import { Z_INDEX } from 'theme/zIndex'
const PopoverContainer = styled.div<{ show: boolean }>`
z-index: 9999;
z-index: ${Z_INDEX.popover};
visibility: ${(props) => (props.show ? 'visible' : 'hidden')};
opacity: ${(props) => (props.show ? 1 : 0)};
transition: visibility 150ms linear, opacity 150ms linear;

View File

@@ -7,7 +7,8 @@ import { useEffect } from 'react'
import { MessageCircle, X } from 'react-feather'
import { useShowSurveyPopup } from 'state/user/hooks'
import styled, { useTheme } from 'styled-components/macro'
import { ExternalLink, ThemedText, Z_INDEX } from 'theme'
import { ExternalLink, ThemedText } from 'theme'
import { Z_INDEX } from 'theme/zIndex'
import BGImage from '../../assets/images/survey-orb.svg'

View File

@@ -50,7 +50,7 @@ const ToggleWrap = styled.div`
`
const ToggleLabel = styled.div`
opacity: 0.6;
opacity: ${({ theme }) => theme.opacity.hover};
margin-right: 10px;
`

View File

@@ -10,7 +10,8 @@ import { RoutingDiagramEntry } from 'components/swap/SwapRoute'
import { useTokenInfoFromActiveList } from 'hooks/useTokenInfoFromActiveList'
import { Box } from 'rebass'
import styled from 'styled-components/macro'
import { ThemedText, Z_INDEX } from 'theme'
import { ThemedText } from 'theme'
import { Z_INDEX } from 'theme/zIndex'
import { ReactComponent as DotLine } from '../../assets/svg/dot_line.svg'
import { MouseoverTooltip } from '../Tooltip'

View File

@@ -165,7 +165,7 @@ function CurrencyRow({
selected={otherSelected}
>
<Column>
<CurrencyLogo currency={currency} size={'24px'} />
<CurrencyLogo currency={currency} size={'36px'} />
</Column>
<AutoColumn>
<Row>

View File

@@ -8,6 +8,7 @@ import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo'
import { useUserAddedTokens } from 'state/user/hooks'
import useLast from '../../hooks/useLast'
import { useWindowSize } from '../../hooks/useWindowSize'
import Modal from '../Modal'
import { CurrencySearch } from './CurrencySearch'
import { ImportList } from './ImportList'
@@ -97,11 +98,16 @@ export default memo(function CurrencySearchModal({
[setModalView, prevView]
)
const { height: windowHeight } = useWindowSize()
// change min height if not searching
let minHeight: number | undefined = 80
let modalHeight: number | undefined = 80
let content = null
switch (modalView) {
case CurrencyModalView.search:
if (windowHeight) {
// Converts pixel units to vh for Modal component
modalHeight = Math.min(Math.round((680 / windowHeight) * 100), 80)
}
content = (
<CurrencySearch
isOpen={isOpen}
@@ -119,7 +125,7 @@ export default memo(function CurrencySearchModal({
)
break
case CurrencyModalView.tokenSafety:
minHeight = undefined
modalHeight = undefined
if (tokenSafetyFlag === TokenSafetyVariant.Enabled && warningToken) {
content = (
<TokenSafety
@@ -133,7 +139,7 @@ export default memo(function CurrencySearchModal({
break
case CurrencyModalView.importToken:
if (importToken) {
minHeight = undefined
modalHeight = undefined
if (tokenSafetyFlag === TokenSafetyVariant.Enabled) {
showTokenSafetySpeedbump(importToken)
}
@@ -149,7 +155,7 @@ export default memo(function CurrencySearchModal({
}
break
case CurrencyModalView.importList:
minHeight = 40
modalHeight = 40
if (importList && listURL) {
content = <ImportList list={importList} listURL={listURL} onDismiss={onDismiss} setModalView={setModalView} />
}
@@ -167,7 +173,7 @@ export default memo(function CurrencySearchModal({
break
}
return (
<Modal isOpen={isOpen} onDismiss={onDismiss} maxHeight={80} minHeight={minHeight}>
<Modal isOpen={isOpen} onDismiss={onDismiss} maxHeight={modalHeight} minHeight={modalHeight}>
{content}
</Modal>
)

View File

@@ -8,7 +8,7 @@ import { navigatorLocale, useActiveLocale } from '../../hooks/useActiveLocale'
import { StyledInternalLink, ThemedText } from '../../theme'
const Container = styled(ThemedText.DeprecatedSmall)`
opacity: 0.6;
opacity: ${({ theme }) => theme.opacity.hover};
:hover {
opacity: 1;
}

View File

@@ -1,13 +1,8 @@
import Modal from '../Modal'
import TokenSafety from '.'
import TokenSafety, { TokenSafetyProps } from '.'
interface TokenSafetyModalProps {
interface TokenSafetyModalProps extends TokenSafetyProps {
isOpen: boolean
tokenAddress: string | null
secondTokenAddress?: string
onContinue: () => void
onCancel: () => void
showCancel?: boolean
}
export default function TokenSafetyModal({
@@ -16,6 +11,7 @@ export default function TokenSafetyModal({
secondTokenAddress,
onContinue,
onCancel,
onBlocked,
showCancel,
}: TokenSafetyModalProps) {
return (
@@ -23,8 +19,9 @@ export default function TokenSafetyModal({
<TokenSafety
tokenAddress={tokenAddress}
secondTokenAddress={secondTokenAddress}
onCancel={onCancel}
onContinue={onContinue}
onBlocked={onBlocked}
onCancel={onCancel}
showCancel={showCancel}
/>
</Modal>

View File

@@ -64,7 +64,7 @@ const StyledCloseButton = styled(StyledButton)`
&:hover {
background-color: ${({ theme }) => theme.backgroundInteractive};
opacity: 0.6;
opacity: ${({ theme }) => theme.opacity.hover};
transition: opacity 250ms ease;
}
`
@@ -73,11 +73,13 @@ const Buttons = ({
warning,
onContinue,
onCancel,
onBlocked,
showCancel,
}: {
warning: Warning
onContinue: () => void
onCancel: () => void
onBlocked?: () => void
showCancel?: boolean
}) => {
return warning.canProceed ? (
@@ -88,7 +90,7 @@ const Buttons = ({
{showCancel && <StyledCancelButton onClick={onCancel}>Cancel</StyledCancelButton>}
</>
) : (
<StyledCloseButton onClick={onCancel}>
<StyledCloseButton onClick={onBlocked ?? onCancel}>
<Trans>Close</Trans>
</StyledCloseButton>
)
@@ -130,10 +132,10 @@ const ExplorerLinkWrapper = styled.div`
cursor: pointer;
:hover {
opacity: 0.6;
opacity: ${({ theme }) => theme.opacity.hover};
}
:active {
opacity: 0.4;
opacity: ${({ theme }) => theme.opacity.click};
}
`
@@ -184,11 +186,12 @@ const StyledExternalLink = styled(ExternalLink)`
font-weight: 600;
`
interface TokenSafetyProps {
export interface TokenSafetyProps {
tokenAddress: string | null
secondTokenAddress?: string
onContinue: () => void
onCancel: () => void
onBlocked?: () => void
showCancel?: boolean
}
@@ -197,6 +200,7 @@ export default function TokenSafety({
secondTokenAddress,
onContinue,
onCancel,
onBlocked,
showCancel,
}: TokenSafetyProps) {
const logos = []
@@ -261,7 +265,13 @@ export default function TokenSafety({
</InfoText>
</ShortColumn>
<LinkColumn>{urls}</LinkColumn>
<Buttons warning={displayWarning} onContinue={acknowledge} onCancel={onCancel} showCancel={showCancel} />
<Buttons
warning={displayWarning}
onContinue={acknowledge}
onCancel={onCancel}
onBlocked={onBlocked}
showCancel={showCancel}
/>
</Container>
</Wrapper>
)

View File

@@ -0,0 +1,103 @@
import { Trans } from '@lingui/macro'
import { darken } from 'polished'
import { useState } from 'react'
import styled from 'styled-components/macro'
import Resource from './Resource'
const NoInfoAvailable = styled.span`
color: ${({ theme }) => theme.textTertiary};
font-weight: 400;
font-size: 16px;
`
const TokenDescriptionContainer = styled.div`
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
max-height: fit-content;
padding-top: 16px;
line-height: 24px;
white-space: pre-wrap;
`
const TruncateDescriptionButton = styled.div`
color: ${({ theme }) => theme.textSecondary};
font-weight: 400;
font-size: 14px;
padding-top: 14px;
&:hover,
&:focus {
color: ${({ theme }) => darken(0.1, theme.textSecondary)};
cursor: pointer;
}
`
const truncateDescription = (desc: string) => {
//trim the string to the maximum length
let tokenDescriptionTruncated = desc.slice(0, TRUNCATE_CHARACTER_COUNT)
//re-trim if we are in the middle of a word
tokenDescriptionTruncated = `${tokenDescriptionTruncated.slice(
0,
Math.min(tokenDescriptionTruncated.length, tokenDescriptionTruncated.lastIndexOf(' '))
)}...`
return tokenDescriptionTruncated
}
const TRUNCATE_CHARACTER_COUNT = 400
export const AboutContainer = styled.div`
gap: 16px;
padding: 24px 0px;
`
export const AboutHeader = styled.span`
font-size: 28px;
line-height: 36px;
`
export const ResourcesContainer = styled.div`
display: flex;
padding-top: 12px;
gap: 14px;
`
type AboutSectionProps = {
address: string
description?: string | null | undefined
homepageUrl?: string | null | undefined
twitterName?: string | null | undefined
}
export function AboutSection({ address, description, homepageUrl, twitterName }: AboutSectionProps) {
const [isDescriptionTruncated, setIsDescriptionTruncated] = useState(true)
const shouldTruncate = !!description && description.length > TRUNCATE_CHARACTER_COUNT
const tokenDescription = shouldTruncate && isDescriptionTruncated ? truncateDescription(description) : description
return (
<AboutContainer>
<AboutHeader>
<Trans>About</Trans>
</AboutHeader>
<TokenDescriptionContainer>
{!description && (
<NoInfoAvailable>
<Trans>No token information available</Trans>
</NoInfoAvailable>
)}
{tokenDescription}
{shouldTruncate && (
<TruncateDescriptionButton onClick={() => setIsDescriptionTruncated(!isDescriptionTruncated)}>
{isDescriptionTruncated ? <Trans>Read more</Trans> : <Trans>Hide</Trans>}
</TruncateDescriptionButton>
)}
</TokenDescriptionContainer>
<ResourcesContainer>
<Resource name={'Etherscan'} link={`https://etherscan.io/address/${address}`} />
<Resource name={'Protocol info'} link={`https://info.uniswap.org/#/tokens/${address}`} />
{homepageUrl && <Resource name={'Website'} link={homepageUrl} />}
{twitterName && <Resource name={'Twitter'} link={`https://twitter.com/${twitterName}`} />}
</ResourcesContainer>
</AboutContainer>
)
}

View File

@@ -0,0 +1,36 @@
import { Trans } from '@lingui/macro'
import styled from 'styled-components/macro'
import { CopyContractAddress } from 'theme'
export const ContractAddressSection = styled.div`
display: flex;
flex-direction: column;
color: ${({ theme }) => theme.textSecondary};
font-weight: 600;
font-size: 14px;
gap: 4px;
padding: 36px 0px;
`
const ContractAddress = styled.button`
display: flex;
color: ${({ theme }) => theme.textPrimary};
gap: 10px;
align-items: center;
background: transparent;
border: none;
min-height: 38px;
padding: 0px;
cursor: pointer;
`
export default function AddressSection({ address }: { address: string }) {
return (
<ContractAddressSection>
<Trans>Contract address</Trans>
<ContractAddress>
<CopyContractAddress address={address} />
</ContractAddress>
</ContractAddressSection>
)
}

View File

@@ -1,12 +1,12 @@
import { Trans } from '@lingui/macro'
import { useWeb3React } from '@web3-react/core'
import { getChainInfoOrDefault } from 'constants/chainInfo'
import { formatToDecimal } from 'components/AmplitudeAnalytics/utils'
import { useToken } from 'hooks/Tokens'
import { useNetworkTokenBalances } from 'hooks/useNetworkTokenBalances'
import { useStablecoinValue } from 'hooks/useStablecoinPrice'
import { useTokenBalance } from 'lib/hooks/useCurrencyBalance'
import { AlertTriangle } from 'react-feather'
import styled, { useTheme } from 'styled-components/macro'
import NetworkBalance from './NetworkBalance'
import styled from 'styled-components/macro'
const BalancesCard = styled.div`
width: 100%;
@@ -33,14 +33,9 @@ const ErrorText = styled.span`
display: flex;
flex-wrap: wrap;
`
const NetworkBalancesSection = styled.div`
height: fit-content;
`
const TotalBalanceSection = styled.div`
height: fit-content;
border-bottom: 1px solid ${({ theme }) => theme.backgroundOutline};
margin-bottom: 20px;
padding-bottom: 20px;
`
const TotalBalance = styled.div`
display: flex;
@@ -54,58 +49,35 @@ const TotalBalanceItem = styled.div`
display: flex;
`
export default function BalanceSummary({
address,
networkBalances,
totalBalance,
}: {
address: string
networkBalances: (JSX.Element | null)[] | null
totalBalance: number
}) {
const theme = useTheme()
const tokenSymbol = useToken(address)?.symbol
const { loading, error, data } = useNetworkTokenBalances({ address })
export default function BalanceSummary({ address }: { address: string }) {
const token = useToken(address)
const { loading, error } = useNetworkTokenBalances({ address })
const { chainId: connectedChainId } = useWeb3React()
const { account } = useWeb3React()
const balance = useTokenBalance(account, token ?? undefined)
const balanceNumber = balance ? formatToDecimal(balance, Math.min(balance.currency.decimals, 6)) : undefined
const balanceUsd = useStablecoinValue(balance)?.toFixed(2)
const balanceUsdNumber = balanceUsd ? parseFloat(balanceUsd) : undefined
const { label: connectedLabel, logoUrl: connectedLogoUrl } = getChainInfoOrDefault(connectedChainId)
const connectedFiatValue = 1
const multipleBalances = true // for testing purposes
if (loading) return null
if (loading || (!error && !balanceNumber && !balanceUsdNumber)) return null
return (
<BalancesCard>
{error ? (
<ErrorState>
<AlertTriangle size={24} />
<ErrorText>
<Trans>There was an error loading your {tokenSymbol} balance</Trans>
<Trans>There was an error loading your {token?.symbol} balance</Trans>
</ErrorText>
</ErrorState>
) : multipleBalances ? (
<>
<TotalBalanceSection>
Your balance across all networks
<TotalBalance>
<TotalBalanceItem>{`${totalBalance} ${tokenSymbol}`}</TotalBalanceItem>
<TotalBalanceItem>$4,210.12</TotalBalanceItem>
</TotalBalance>
</TotalBalanceSection>
<NetworkBalancesSection>Your balances by network</NetworkBalancesSection>
{data && networkBalances}
</>
) : (
<>
Your balance on {connectedLabel}
<NetworkBalance
logoUrl={connectedLogoUrl}
balance={'1'}
tokenSymbol={tokenSymbol ?? 'XXX'}
fiatValue={connectedFiatValue}
label={connectedLabel}
networkColor={theme.textPrimary}
/>
<TotalBalanceSection>
Your balance
<TotalBalance>
<TotalBalanceItem>{`${balanceNumber} ${token?.symbol}`}</TotalBalanceItem>
<TotalBalanceItem>{`$${balanceUsdNumber}`}</TotalBalanceItem>
</TotalBalance>
</TotalBalanceSection>
</>
)}
</BalancesCard>

View File

@@ -0,0 +1,18 @@
import { Link } from 'react-router-dom'
import styled from 'styled-components/macro'
export const BreadcrumbNavLink = styled(Link)`
display: flex;
color: ${({ theme }) => theme.textSecondary};
font-size: 14px;
line-height: 20px;
align-items: center;
gap: 4px;
text-decoration: none;
margin-bottom: 16px;
transition-duration: ${({ theme }) => theme.transition.duration.fast};
&:hover {
color: ${({ theme }) => theme.textTertiary};
}
`

View File

@@ -0,0 +1,117 @@
import { Trans } from '@lingui/macro'
import { Token } from '@uniswap/sdk-core'
import { ParentSize } from '@visx/responsive'
import { useWeb3React } from '@web3-react/core'
import CurrencyLogo from 'components/CurrencyLogo'
import { VerifiedIcon } from 'components/TokenSafety/TokenSafetyIcon'
import { getChainInfo } from 'constants/chainInfo'
import { nativeOnChain, WRAPPED_NATIVE_CURRENCY } from 'constants/tokens'
import { checkWarning } from 'constants/tokenSafety'
import { SingleTokenData } from 'graphql/data/Token'
import { useCurrency } from 'hooks/Tokens'
import styled from 'styled-components/macro'
import { useIsFavorited, useToggleFavorite } from '../state'
import { ClickFavorited, FavoriteIcon } from '../TokenTable/TokenRow'
import PriceChart from './PriceChart'
import ShareButton from './ShareButton'
export const ChartHeader = styled.div`
width: 100%;
display: flex;
flex-direction: column;
color: ${({ theme }) => theme.textPrimary};
gap: 4px;
margin-bottom: 24px;
`
export const TokenInfoContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`
export const ChartContainer = styled.div`
display: flex;
height: 436px;
align-items: center;
`
export const TokenNameCell = styled.div`
display: flex;
gap: 8px;
font-size: 20px;
line-height: 28px;
align-items: center;
`
const TokenSymbol = styled.span`
text-transform: uppercase;
color: ${({ theme }) => theme.textSecondary};
`
const TokenActions = styled.div`
display: flex;
gap: 16px;
color: ${({ theme }) => theme.textSecondary};
`
const NetworkBadge = styled.div<{ networkColor?: string; backgroundColor?: string }>`
border-radius: 5px;
padding: 4px 8px;
font-weight: 600;
font-size: 12px;
line-height: 12px;
color: ${({ theme, networkColor }) => networkColor ?? theme.textPrimary};
background-color: ${({ theme, backgroundColor }) => backgroundColor ?? theme.backgroundSurface};
`
export default function ChartSection({ token, tokenData }: { token: Token; tokenData: SingleTokenData | undefined }) {
const { chainId: connectedChainId } = useWeb3React()
const isFavorited = useIsFavorited(token.address)
const toggleFavorite = useToggleFavorite(token.address)
const chainInfo = getChainInfo(token?.chainId)
const networkLabel = chainInfo?.label
const networkBadgebackgroundColor = chainInfo?.backgroundColor
const warning = checkWarning(token.address)
let currency = useCurrency(token.address)
if (connectedChainId) {
const wrappedNativeCurrency = WRAPPED_NATIVE_CURRENCY[connectedChainId]
const isWrappedNativeToken = wrappedNativeCurrency?.address === token?.address
if (isWrappedNativeToken) {
currency = nativeOnChain(connectedChainId)
}
}
const tokenName = tokenData?.name ?? token?.name
const tokenSymbol = tokenData?.tokens?.[0]?.symbol ?? token?.symbol
return (
<ChartHeader>
<TokenInfoContainer>
<TokenNameCell>
<CurrencyLogo currency={currency} size={'32px'} symbol={tokenSymbol} />
{tokenName ?? <Trans>Name not found</Trans>}
<TokenSymbol>{tokenSymbol ?? <Trans>Symbol not found</Trans>}</TokenSymbol>
{!warning && <VerifiedIcon size="20px" />}
{networkBadgebackgroundColor && (
<NetworkBadge networkColor={chainInfo?.color} backgroundColor={networkBadgebackgroundColor}>
{networkLabel}
</NetworkBadge>
)}
</TokenNameCell>
<TokenActions>
{tokenName && tokenSymbol && (
<ShareButton tokenName={tokenName} tokenSymbol={tokenSymbol} tokenAddress={token.address} />
)}
<ClickFavorited onClick={toggleFavorite}>
<FavoriteIcon isFavorited={isFavorited} />
</ClickFavorited>
</TokenActions>
</TokenInfoContainer>
<ChartContainer>
<ParentSize>
{({ width, height }) => (
<PriceChart tokenAddress={token.address} width={width} height={height} priceData={tokenData?.prices?.[0]} />
)}
</ParentSize>
</ChartContainer>
</ChartHeader>
)
}

View File

@@ -1,22 +1,13 @@
import { Footer, LeftPanel, RightPanel, TokenDetailsLayout } from 'pages/TokenDetails'
import styled, { useTheme } from 'styled-components/macro'
import { LoadingBubble } from '../loading'
import { AboutContainer, AboutHeader, ResourcesContainer } from './About'
import { ContractAddressSection } from './AddressSection'
import { BreadcrumbNavLink } from './BreadcrumbNavLink'
import { ChartContainer, ChartHeader, TokenInfoContainer, TokenNameCell } from './ChartSection'
import { DeltaContainer, TokenPrice } from './PriceChart'
import {
AboutContainer,
AboutHeader,
BreadcrumbNavLink,
ChartContainer,
ChartHeader,
ContractAddressSection,
ResourcesContainer,
Stat,
StatPair,
StatsSection,
TokenInfoContainer,
TokenNameCell,
TopArea,
} from './TokenDetailContainers'
import { StatPair, StatWrapper, TokenStatsSection } from './StatsSection'
const LoadingChartContainer = styled(ChartContainer)`
height: 336px;
@@ -90,7 +81,7 @@ export function Wave() {
/* Loading State: row component with loading bubbles */
export default function LoadingTokenDetail() {
return (
<TopArea>
<LeftPanel>
<BreadcrumbNavLink to="/explore">
<Space heightSize={20} />
</BreadcrumbNavLink>
@@ -120,30 +111,30 @@ export default function LoadingTokenDetail() {
</LoadingChartContainer>
<Space heightSize={32} />
</ChartHeader>
<StatsSection>
<TokenStatsSection>
<StatsLoadingContainer>
<StatPair>
<Stat>
<StatWrapper>
<HalfLoadingBubble />
<StatLoadingBubble />
</Stat>
<Stat>
</StatWrapper>
<StatWrapper>
<HalfLoadingBubble />
<StatLoadingBubble />
</Stat>
</StatWrapper>
</StatPair>
<StatPair>
<Stat>
<StatWrapper>
<HalfLoadingBubble />
<StatLoadingBubble />
</Stat>
<Stat>
</StatWrapper>
<StatWrapper>
<HalfLoadingBubble />
<StatLoadingBubble />
</Stat>
</StatWrapper>
</StatPair>
</StatsLoadingContainer>
</StatsSection>
</TokenStatsSection>
<AboutContainer>
<AboutHeader>
<SquareLoadingBubble />
@@ -155,6 +146,16 @@ export default function LoadingTokenDetail() {
<ResourcesContainer>{null}</ResourcesContainer>
</AboutContainer>
<ContractAddressSection>{null}</ContractAddressSection>
</TopArea>
</LeftPanel>
)
}
export function LoadingTokenDetails() {
return (
<TokenDetailsLayout>
<LoadingTokenDetail />
<RightPanel />
<Footer />
</TokenDetailsLayout>
)
}

View File

@@ -1,24 +1,23 @@
import { Token } from '@uniswap/sdk-core'
import { AxisBottom, TickFormatter } from '@visx/axis'
import { localPoint } from '@visx/event'
import { EventType } from '@visx/event/lib/types'
import { GlyphCircle } from '@visx/glyph'
import { Line } from '@visx/shape'
import { filterTimeAtom } from 'components/Tokens/state'
import { bisect, curveCardinalOpen, NumberValue, scaleLinear } from 'd3'
import { useTokenPriceQuery } from 'graphql/data/TokenPriceQuery'
import { TimePeriod } from 'graphql/data/TopTokenQuery'
import { bisect, curveCardinal, NumberValue, scaleLinear, timeDay, timeHour, timeMinute, timeMonth } from 'd3'
import { TokenPrices$key } from 'graphql/data/__generated__/TokenPrices.graphql'
import { useTokenPricesCached } from 'graphql/data/Token'
import { PricePoint, TimePeriod } from 'graphql/data/Token'
import { useActiveLocale } from 'hooks/useActiveLocale'
import { useAtom } from 'jotai'
import { useCallback, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { ArrowDownRight, ArrowUpRight } from 'react-feather'
import styled, { useTheme } from 'styled-components/macro'
import { OPACITY_HOVER } from 'theme'
import {
dayHourFormatter,
hourFormatter,
monthDayFormatter,
monthFormatter,
monthTickFormatter,
monthYearDayFormatter,
monthYearFormatter,
weekFormatter,
@@ -29,11 +28,9 @@ import { DISPLAYS, ORDERED_TIMES } from '../TokenTable/TimeSelector'
// TODO: This should be combined with the logic in TimeSelector.
export type PricePoint = { value: number; timestamp: number }
export const DATA_EMPTY = { value: 0, timestamp: 0 }
function getPriceBounds(pricePoints: PricePoint[]): [number, number] {
export function getPriceBounds(pricePoints: PricePoint[]): [number, number] {
const prices = pricePoints.map((x) => x.value)
const min = Math.min(...prices)
const max = Math.max(...prices)
@@ -72,7 +69,6 @@ export function formatDelta(delta: number) {
export const ChartHeader = styled.div`
position: absolute;
`
export const TokenPrice = styled.span`
font-size: 36px;
line-height: 44px;
@@ -112,40 +108,12 @@ const TimeButton = styled.button<{ active: boolean }>`
border: none;
cursor: pointer;
color: ${({ theme, active }) => (active ? theme.textPrimary : theme.textSecondary)};
transition-duration: ${({ theme }) => theme.transition.duration.fast};
:hover {
${({ active }) => !active && `opacity: ${OPACITY_HOVER};`}
${({ active, theme }) => !active && `opacity: ${theme.opacity.hover};`}
}
`
function getTicks(startTimestamp: number, endTimestamp: number, numTicks = 5) {
return Array.from(
{ length: numTicks },
(v, i) => endTimestamp - ((endTimestamp - startTimestamp) / (numTicks + 1)) * (i + 1)
)
}
function tickFormat(
startTimestamp: number,
endTimestamp: number,
timePeriod: TimePeriod,
locale: string
): [TickFormatter<NumberValue>, (v: number) => string, number[]] {
switch (timePeriod) {
case TimePeriod.HOUR:
return [hourFormatter(locale), dayHourFormatter(locale), getTicks(startTimestamp, endTimestamp)]
case TimePeriod.DAY:
return [hourFormatter(locale), dayHourFormatter(locale), getTicks(startTimestamp, endTimestamp)]
case TimePeriod.WEEK:
return [weekFormatter(locale), dayHourFormatter(locale), getTicks(startTimestamp, endTimestamp, 6)]
case TimePeriod.MONTH:
return [monthDayFormatter(locale), dayHourFormatter(locale), getTicks(startTimestamp, endTimestamp)]
case TimePeriod.YEAR:
return [monthFormatter(locale), monthYearDayFormatter(locale), getTicks(startTimestamp, endTimestamp)]
case TimePeriod.ALL:
return [monthYearFormatter(locale), monthYearDayFormatter(locale), getTicks(startTimestamp, endTimestamp)]
}
}
const margin = { top: 100, bottom: 48, crosshair: 72 }
const timeOptionsHeight = 44
const crosshairDateOverhang = 80
@@ -153,50 +121,104 @@ const crosshairDateOverhang = 80
interface PriceChartProps {
width: number
height: number
token: Token
tokenAddress: string
priceData?: TokenPrices$key | null
}
export function PriceChart({ width, height, token }: PriceChartProps) {
export function PriceChart({ width, height, tokenAddress, priceData }: PriceChartProps) {
const [timePeriod, setTimePeriod] = useAtom(filterTimeAtom)
const locale = useActiveLocale()
const theme = useTheme()
// TODO: Add network selector input, consider using backend type instead of current front end selector type
const pricePoints: PricePoint[] = useTokenPriceQuery(token.address, timePeriod, 'ETHEREUM').filter(
(p): p is PricePoint => Boolean(p && p.value)
)
const { priceMap } = useTokenPricesCached(priceData, tokenAddress, 'ETHEREUM', timePeriod)
const prices = priceMap.get(timePeriod)
const hasData = pricePoints.length !== 0
/* TODO: Implement API calls & cache to use here */
const startingPrice = hasData ? pricePoints[0] : DATA_EMPTY
const endingPrice = hasData ? pricePoints[pricePoints.length - 1] : DATA_EMPTY
const startingPrice = prices?.[0] ?? DATA_EMPTY
const endingPrice = prices?.[prices.length - 1] ?? DATA_EMPTY
const [displayPrice, setDisplayPrice] = useState(startingPrice)
const [crosshair, setCrosshair] = useState<number | null>(null)
const graphWidth = width + crosshairDateOverhang
// TODO: remove this logic after suspense is properly added
const graphHeight = height - timeOptionsHeight > 0 ? height - timeOptionsHeight : 0
const graphInnerHeight = graphHeight - margin.top - margin.bottom > 0 ? graphHeight - margin.top - margin.bottom : 0
// Defining scales
// x scale
const timeScale = scaleLinear().domain([startingPrice.timestamp, endingPrice.timestamp]).range([0, width])
const timeScale = useMemo(
() => scaleLinear().domain([startingPrice.timestamp, endingPrice.timestamp]).range([0, width]).nice(),
[startingPrice, endingPrice, width]
)
// y scale
const rdScale = scaleLinear().domain(getPriceBounds(pricePoints)).range([graphInnerHeight, 0])
const rdScale = useMemo(
() =>
scaleLinear()
.domain(getPriceBounds(prices ?? []))
.range([graphInnerHeight, 0]),
[prices, graphInnerHeight]
)
function tickFormat(
startTimestamp: number,
endTimestamp: number,
timePeriod: TimePeriod,
locale: string
): [TickFormatter<NumberValue>, (v: number) => string, NumberValue[]] {
const startDate = new Date(startingPrice.timestamp.valueOf() * 1000)
const endDate = new Date(endingPrice.timestamp.valueOf() * 1000)
switch (timePeriod) {
case TimePeriod.HOUR:
return [
hourFormatter(locale),
dayHourFormatter(locale),
timeMinute.range(startDate, endDate, 10).map((x) => x.valueOf() / 1000),
]
case TimePeriod.DAY:
return [
hourFormatter(locale),
dayHourFormatter(locale),
timeHour.range(startDate, endDate, 4).map((x) => x.valueOf() / 1000),
]
case TimePeriod.WEEK:
return [
weekFormatter(locale),
dayHourFormatter(locale),
timeDay.range(startDate, endDate, 1).map((x) => x.valueOf() / 1000),
]
case TimePeriod.MONTH:
return [
monthDayFormatter(locale),
dayHourFormatter(locale),
timeDay.range(startDate, endDate, 7).map((x) => x.valueOf() / 1000),
]
case TimePeriod.YEAR:
return [
monthTickFormatter(locale),
monthYearDayFormatter(locale),
timeMonth.range(startDate, endDate, 2).map((x) => x.valueOf() / 1000),
]
case TimePeriod.ALL:
return [
monthYearFormatter(locale),
monthYearDayFormatter(locale),
timeMonth.range(startDate, endDate, 6).map((x) => x.valueOf() / 1000),
]
}
}
const handleHover = useCallback(
(event: Element | EventType) => {
if (!prices) return
const { x } = localPoint(event) || { x: 0 }
const x0 = timeScale.invert(x) // get timestamp from the scalexw
const index = bisect(
pricePoints.map((x) => x.timestamp),
prices.map((x) => x.timestamp),
x0,
1
)
const d0 = pricePoints[index - 1]
const d1 = pricePoints[index]
const d0 = prices[index - 1]
const d1 = prices[index]
let pricePoint = d0
const hasPreviousData = d1 && d1.timestamp
@@ -207,7 +229,7 @@ export function PriceChart({ width, height, token }: PriceChartProps) {
setCrosshair(timeScale(pricePoint.timestamp))
setDisplayPrice(pricePoint)
},
[timeScale, pricePoints]
[timeScale, prices]
)
const resetDisplay = useCallback(() => {
@@ -215,8 +237,8 @@ export function PriceChart({ width, height, token }: PriceChartProps) {
setDisplayPrice(endingPrice)
}, [setCrosshair, setDisplayPrice, endingPrice])
// TODO: connect to loading state
if (!hasData) {
// TODO: Display no data available error
if (!prices) {
return null
}
@@ -232,8 +254,8 @@ export function PriceChart({ width, height, token }: PriceChartProps) {
const crosshairEdgeMax = width * 0.85
const crosshairAtEdge = !!crosshair && crosshair > crosshairEdgeMax
/* Default curve doesn't look good for the ALL chart */
const curveTension = timePeriod === TimePeriod.ALL ? 0.75 : 0.9
/* Default curve doesn't look good for the HOUR/ALL chart */
const curveTension = timePeriod === TimePeriod.ALL ? 0.75 : timePeriod === TimePeriod.HOUR ? 1 : 0.9
return (
<>
@@ -245,11 +267,11 @@ export function PriceChart({ width, height, token }: PriceChartProps) {
</DeltaContainer>
</ChartHeader>
<LineChart
data={pricePoints}
data={prices}
getX={(p: PricePoint) => timeScale(p.timestamp)}
getY={(p: PricePoint) => rdScale(p.value)}
marginTop={margin.top}
curve={curveCardinalOpen.tension(curveTension)}
curve={curveCardinal.tension(curveTension)}
strokeWidth={2}
width={graphWidth}
height={graphHeight}
@@ -316,7 +338,13 @@ export function PriceChart({ width, height, token }: PriceChartProps) {
<TimeOptionsWrapper>
<TimeOptionsContainer>
{ORDERED_TIMES.map((time) => (
<TimeButton key={DISPLAYS[time]} active={timePeriod === time} onClick={() => setTimePeriod(time)}>
<TimeButton
key={DISPLAYS[time]}
active={timePeriod === time}
onClick={() => {
setTimePeriod(time)
}}
>
{DISPLAYS[time]}
</TimeButton>
))}

View File

@@ -5,9 +5,10 @@ import { Twitter } from 'react-feather'
import { useModalIsOpen, useToggleModal } from 'state/application/hooks'
import { ApplicationModal } from 'state/application/reducer'
import styled, { useTheme } from 'styled-components/macro'
import { ClickableStyle, CopyHelperRefType, OPACITY_CLICK, Z_INDEX } from 'theme'
import { ClickableStyle, CopyHelperRefType } from 'theme'
import { colors } from 'theme/colors'
import { opacify } from 'theme/utils'
import { Z_INDEX } from 'theme/zIndex'
import { ReactComponent as ShareIcon } from '../../../assets/svg/share.svg'
import { CopyHelper } from '../../../theme'
@@ -25,7 +26,7 @@ const Share = styled(ShareIcon)<{ open: boolean }>`
height: 24px;
width: 24px;
${ClickableStyle}
${({ open }) => open && `opacity: ${OPACITY_CLICK} !important`};
${({ open, theme }) => open && `opacity: ${theme.opacity.click} !important`};
`
const ShareActions = styled.div`

View File

@@ -0,0 +1,67 @@
import { Trans } from '@lingui/macro'
import { ReactNode } from 'react'
import styled from 'styled-components/macro'
import { formatDollarAmount } from 'utils/formatDollarAmt'
export const StatWrapper = styled.div`
display: flex;
flex-direction: column;
color: ${({ theme }) => theme.textSecondary};
font-size: 14px;
min-width: 168px;
flex: 1;
gap: 4px;
padding: 24px 0px;
`
export const TokenStatsSection = styled.div`
display: flex;
flex-wrap: wrap;
`
export const StatPair = styled.div`
display: flex;
flex: 1;
flex-wrap: wrap;
`
const StatPrice = styled.span`
font-size: 28px;
color: ${({ theme }) => theme.textPrimary};
`
const NoData = styled.div`
color: ${({ theme }) => theme.textTertiary};
`
type NumericStat = number | undefined | null
function Stat({ value, title }: { value: NumericStat; title: ReactNode }) {
return (
<StatWrapper>
{title}
<StatPrice>{value ? formatDollarAmount(value) : '-'}</StatPrice>
</StatWrapper>
)
}
type StatsSectionProps = {
marketCap?: NumericStat
volume24H?: NumericStat
priceLow52W?: NumericStat
priceHigh52W?: NumericStat
}
export default function StatsSection({ marketCap, volume24H, priceLow52W, priceHigh52W }: StatsSectionProps) {
if (marketCap || volume24H || priceLow52W || priceHigh52W) {
return (
<TokenStatsSection>
<StatPair>
<Stat value={marketCap} title={<Trans>Market Cap</Trans>} />
<Stat value={volume24H} title={<Trans>24H volume</Trans>} />
</StatPair>
<StatPair>
<Stat value={priceLow52W} title={<Trans>52W low</Trans>} />
<Stat value={priceHigh52W} title={<Trans>52W high</Trans>} />
</StatPair>
</TokenStatsSection>
)
} else {
return <NoData>No stats available</NoData>
}
}

View File

@@ -1,305 +0,0 @@
import { Trans } from '@lingui/macro'
import { ParentSize } from '@visx/responsive'
import { useWeb3React } from '@web3-react/core'
import CurrencyLogo from 'components/CurrencyLogo'
import PriceChart from 'components/Tokens/TokenDetails/PriceChart'
import { VerifiedIcon } from 'components/TokenSafety/TokenSafetyIcon'
import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
import { getChainInfo } from 'constants/chainInfo'
import { nativeOnChain, WRAPPED_NATIVE_CURRENCY } from 'constants/tokens'
import { checkWarning, WARNING_LEVEL } from 'constants/tokenSafety'
import { chainIdToChainName, useTokenDetailQuery } from 'graphql/data/TokenDetailQuery'
import { useCurrency, useIsUserAddedToken, useToken } from 'hooks/Tokens'
import { useAtomValue } from 'jotai/utils'
import { darken } from 'polished'
import { Suspense, useCallback } from 'react'
import { useState } from 'react'
import { ArrowLeft, Heart } from 'react-feather'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components/macro'
import { ClickableStyle, CopyContractAddress } from 'theme'
import { formatDollarAmount } from 'utils/formatDollarAmt'
import { favoritesAtom, filterNetworkAtom, useToggleFavorite } from '../state'
import { ClickFavorited } from '../TokenTable/TokenRow'
import LoadingTokenDetail from './LoadingTokenDetail'
import Resource from './Resource'
import ShareButton from './ShareButton'
import {
AboutContainer,
AboutHeader,
BreadcrumbNavLink,
ChartContainer,
ChartHeader,
ContractAddressSection,
ResourcesContainer,
Stat,
StatPair,
StatsSection,
TokenInfoContainer,
TokenNameCell,
TopArea,
} from './TokenDetailContainers'
const ContractAddress = styled.button`
display: flex;
color: ${({ theme }) => theme.textPrimary};
gap: 10px;
align-items: center;
background: transparent;
border: none;
min-height: 38px;
padding: 0px;
cursor: pointer;
`
const Contract = styled.div`
display: flex;
flex-direction: column;
color: ${({ theme }) => theme.textSecondary};
font-size: 14px;
gap: 4px;
`
const StatPrice = styled.span`
font-size: 28px;
color: ${({ theme }) => theme.textPrimary};
`
const TokenActions = styled.div`
display: flex;
gap: 16px;
color: ${({ theme }) => theme.textSecondary};
`
const TokenSymbol = styled.span`
text-transform: uppercase;
color: ${({ theme }) => theme.textSecondary};
`
const NetworkBadge = styled.div<{ networkColor?: string; backgroundColor?: string }>`
border-radius: 5px;
padding: 4px 8px;
font-weight: 600;
font-size: 12px;
line-height: 12px;
color: ${({ theme, networkColor }) => networkColor ?? theme.textPrimary};
background-color: ${({ theme, backgroundColor }) => backgroundColor ?? theme.backgroundSurface};
`
const FavoriteIcon = styled(Heart)<{ isFavorited: boolean }>`
${ClickableStyle}
height: 22px;
width: 24px;
color: ${({ isFavorited, theme }) => (isFavorited ? theme.accentAction : theme.textSecondary)};
fill: ${({ isFavorited, theme }) => (isFavorited ? theme.accentAction : 'transparent')};
`
const NoInfoAvailable = styled.span`
color: ${({ theme }) => theme.textTertiary};
font-weight: 400;
font-size: 16px;
`
const TokenDescriptionContainer = styled.div`
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
max-height: fit-content;
padding-top: 16px;
line-height: 24px;
white-space: pre-wrap;
`
const TruncateDescriptionButton = styled.div`
color: ${({ theme }) => theme.textSecondary};
font-weight: 400;
font-size: 14px;
padding-top: 14px;
&:hover,
&:focus {
color: ${({ theme }) => darken(0.1, theme.textSecondary)};
cursor: pointer;
}
`
const TRUNCATE_CHARACTER_COUNT = 400
type TokenDetailData = {
description: string | null | undefined
homepageUrl: string | null | undefined
twitterName: string | null | undefined
}
const truncateDescription = (desc: string) => {
//trim the string to the maximum length
let tokenDescriptionTruncated = desc.slice(0, TRUNCATE_CHARACTER_COUNT)
//re-trim if we are in the middle of a word
tokenDescriptionTruncated = `${tokenDescriptionTruncated.slice(
0,
Math.min(tokenDescriptionTruncated.length, tokenDescriptionTruncated.lastIndexOf(' '))
)}...`
return tokenDescriptionTruncated
}
export function AboutSection({ address, tokenDetailData }: { address: string; tokenDetailData: TokenDetailData }) {
const [isDescriptionTruncated, setIsDescriptionTruncated] = useState(true)
const shouldTruncate =
tokenDetailData && tokenDetailData.description
? tokenDetailData.description.length > TRUNCATE_CHARACTER_COUNT
: false
const tokenDescription =
tokenDetailData && tokenDetailData.description && shouldTruncate && isDescriptionTruncated
? truncateDescription(tokenDetailData.description)
: tokenDetailData.description
return (
<AboutContainer>
<AboutHeader>
<Trans>About</Trans>
</AboutHeader>
<TokenDescriptionContainer>
{(!tokenDetailData || !tokenDetailData.description) && (
<NoInfoAvailable>
<Trans>No token information available</Trans>
</NoInfoAvailable>
)}
{tokenDescription}
{shouldTruncate && (
<TruncateDescriptionButton onClick={() => setIsDescriptionTruncated(!isDescriptionTruncated)}>
{isDescriptionTruncated ? <Trans>Read more</Trans> : <Trans>Hide</Trans>}
</TruncateDescriptionButton>
)}
</TokenDescriptionContainer>
<ResourcesContainer>
<Resource name={'Etherscan'} link={`https://etherscan.io/address/${address}`} />
<Resource name={'Protocol info'} link={`https://info.uniswap.org/#/tokens/${address}`} />
{tokenDetailData?.homepageUrl && <Resource name={'Website'} link={tokenDetailData.homepageUrl} />}
{tokenDetailData?.twitterName && (
<Resource name={'Twitter'} link={`https://twitter.com/${tokenDetailData.twitterName}`} />
)}
</ResourcesContainer>
</AboutContainer>
)
}
export default function LoadedTokenDetail({ address }: { address: string }) {
const { chainId: connectedChainId } = useWeb3React()
const token = useToken(address)
let currency = useCurrency(address)
const favoriteTokens = useAtomValue<string[]>(favoritesAtom)
const isFavorited = favoriteTokens.includes(address)
const toggleFavorite = useToggleFavorite(address)
const warning = checkWarning(address)
const navigate = useNavigate()
const isUserAddedToken = useIsUserAddedToken(token)
const [warningModalOpen, setWarningModalOpen] = useState(!!warning && !isUserAddedToken)
const handleDismissWarning = useCallback(() => {
setWarningModalOpen(false)
}, [setWarningModalOpen])
const handleCancel = useCallback(() => {
setWarningModalOpen(false)
warning && warning.level === WARNING_LEVEL.BLOCKED && navigate(-1)
}, [setWarningModalOpen, navigate, warning])
const chainInfo = getChainInfo(token?.chainId)
const networkLabel = chainInfo?.label
const networkBadgebackgroundColor = chainInfo?.backgroundColor
const filterNetwork = useAtomValue(filterNetworkAtom)
const tokenDetailData = useTokenDetailQuery(address, chainIdToChainName(filterNetwork))
const relevantTokenDetailData = (({ description, homepageUrl, twitterName }) => ({
description,
homepageUrl,
twitterName,
}))(tokenDetailData)
if (!token || !token.name || !token.symbol || !connectedChainId) {
return <LoadingTokenDetail />
}
const wrappedNativeCurrency = WRAPPED_NATIVE_CURRENCY[connectedChainId]
const isWrappedNativeToken = wrappedNativeCurrency?.address === token.address
if (isWrappedNativeToken) {
currency = nativeOnChain(connectedChainId)
}
const tokenName = isWrappedNativeToken && currency ? currency.name : tokenDetailData.name
const defaultTokenSymbol = tokenDetailData.tokens?.[0]?.symbol ?? token.symbol
const tokenSymbol = isWrappedNativeToken && currency ? currency.symbol : defaultTokenSymbol
return (
<Suspense fallback={<LoadingTokenDetail />}>
<TopArea>
<BreadcrumbNavLink to="/tokens">
<ArrowLeft size={14} /> Tokens
</BreadcrumbNavLink>
<ChartHeader>
<TokenInfoContainer>
<TokenNameCell>
<CurrencyLogo currency={currency} size={'32px'} />
{tokenName ?? <Trans>Name not found</Trans>}
<TokenSymbol>{tokenSymbol ?? <Trans>Symbol not found</Trans>}</TokenSymbol>
{!warning && <VerifiedIcon size="20px" />}
{networkBadgebackgroundColor && (
<NetworkBadge networkColor={chainInfo?.color} backgroundColor={networkBadgebackgroundColor}>
{networkLabel}
</NetworkBadge>
)}
</TokenNameCell>
<TokenActions>
{tokenName && tokenSymbol && (
<ShareButton tokenName={tokenName} tokenSymbol={tokenSymbol} tokenAddress={address} />
)}
<ClickFavorited onClick={toggleFavorite}>
<FavoriteIcon isFavorited={isFavorited} />
</ClickFavorited>
</TokenActions>
</TokenInfoContainer>
<ChartContainer>
<ParentSize>{({ width, height }) => <PriceChart token={token} width={width} height={height} />}</ParentSize>
</ChartContainer>
</ChartHeader>
<StatsSection>
<StatPair>
<Stat>
<Trans>Market cap</Trans>
<StatPrice>
{tokenDetailData.marketCap?.value ? formatDollarAmount(tokenDetailData.marketCap?.value) : '-'}
</StatPrice>
</Stat>
<Stat>
24H volume
<StatPrice>
{tokenDetailData.volume24h?.value ? formatDollarAmount(tokenDetailData.volume24h?.value) : '-'}
</StatPrice>
</Stat>
</StatPair>
<StatPair>
<Stat>
52W low
<StatPrice>
{tokenDetailData.priceLow52W?.value ? formatDollarAmount(tokenDetailData.priceLow52W?.value) : '-'}
</StatPrice>
</Stat>
<Stat>
52W high
<StatPrice>
{tokenDetailData.priceHigh52W?.value ? formatDollarAmount(tokenDetailData.priceHigh52W?.value) : '-'}
</StatPrice>
</Stat>
</StatPair>
</StatsSection>
<AboutSection address={address} tokenDetailData={relevantTokenDetailData} />
<ContractAddressSection>
<Contract>
<Trans>Contract address</Trans>
<ContractAddress>
<CopyContractAddress address={address} />
</ContractAddress>
</Contract>
</ContractAddressSection>
<TokenSafetyModal
isOpen={warningModalOpen}
tokenAddress={address}
onCancel={handleCancel}
onContinue={handleDismissWarning}
/>
</TopArea>
</Suspense>
)
}

View File

@@ -1,81 +0,0 @@
import { Link } from 'react-router-dom'
import styled from 'styled-components/macro'
export const AboutContainer = styled.div`
gap: 16px;
padding: 24px 0px;
`
export const AboutHeader = styled.span`
font-size: 28px;
line-height: 36px;
`
export const BreadcrumbNavLink = styled(Link)`
display: flex;
color: ${({ theme }) => theme.textSecondary};
font-size: 14px;
line-height: 20px;
align-items: center;
gap: 4px;
text-decoration: none;
margin-bottom: 16px;
&:hover {
color: ${({ theme }) => theme.textTertiary};
}
`
export const ChartHeader = styled.div`
width: 100%;
display: flex;
flex-direction: column;
color: ${({ theme }) => theme.textPrimary};
gap: 4px;
margin-bottom: 24px;
`
export const ContractAddressSection = styled.div`
padding: 36px 0px;
`
export const ChartContainer = styled.div`
display: flex;
height: 436px;
align-items: center;
`
export const Stat = styled.div`
display: flex;
flex-direction: column;
color: ${({ theme }) => theme.textSecondary};
font-size: 14px;
min-width: 168px;
flex: 1;
gap: 4px;
padding: 24px 0px;
`
export const StatsSection = styled.div`
display: flex;
flex-wrap: wrap;
`
export const StatPair = styled.div`
display: flex;
flex: 1;
flex-wrap: wrap;
`
export const TokenNameCell = styled.div`
display: flex;
gap: 8px;
font-size: 20px;
line-height: 28px;
align-items: center;
`
export const TokenInfoContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`
export const TopArea = styled.div`
max-width: 832px;
overflow: hidden;
`
export const ResourcesContainer = styled.div`
display: flex;
padding-top: 12px;
gap: 14px;
`

View File

@@ -5,6 +5,7 @@ import styled, { useTheme } from 'styled-components/macro'
import { SMALLEST_MOBILE_MEDIA_BREAKPOINT } from '../constants'
import { showFavoritesAtom } from '../state'
import FilterOption from './FilterOption'
const FavoriteButtonContent = styled.div`
display: flex;
@@ -12,20 +13,6 @@ const FavoriteButtonContent = styled.div`
align-items: center;
gap: 8px;
`
const StyledFavoriteButton = styled.button<{ active: boolean }>`
padding: 0px 16px;
border-radius: 12px;
background-color: ${({ theme, active }) => (active ? theme.accentAction : theme.backgroundInteractive)};
border: none;
color: ${({ theme, active }) => (active ? theme.white : theme.textPrimary)};
font-size: 16px;
font-weight: 600;
cursor: pointer;
:hover {
background-color: ${({ theme, active }) => !active && theme.backgroundModule};
}
`
const FavoriteText = styled.span`
@media only screen and (max-width: ${SMALLEST_MOBILE_MEDIA_BREAKPOINT}) {
display: none;
@@ -36,13 +23,13 @@ export default function FavoriteButton() {
const theme = useTheme()
const [showFavorites, setShowFavorites] = useAtom(showFavoritesAtom)
return (
<StyledFavoriteButton onClick={() => setShowFavorites(!showFavorites)} active={showFavorites}>
<FilterOption onClick={() => setShowFavorites(!showFavorites)} active={showFavorites} highlight>
<FavoriteButtonContent>
<Heart size={17} color={showFavorites ? theme.white : theme.textPrimary} fill="transparent" />
<Heart size={20} color={showFavorites ? theme.accentActive : theme.textPrimary} />
<FavoriteText>
<Trans>Favorites</Trans>
</FavoriteText>
</FavoriteButtonContent>
</StyledFavoriteButton>
</FilterOption>
)
}

View File

@@ -0,0 +1,26 @@
//import { ReactNode } from 'react'
import styled from 'styled-components/macro'
const FilterOption = styled.button<{ active: boolean; highlight?: boolean }>`
height: 100%;
color: ${({ theme, active }) => (active ? theme.accentActive : theme.textPrimary)};
background-color: ${({ theme, active }) => (active ? theme.accentActiveSoft : theme.backgroundInteractive)};
margin: 0;
padding: 6px 12px 6px 14px;
border-radius: 12px;
font-size: 16px;
line-height: 24px;
font-weight: 600;
transition-duration: ${({ theme }) => theme.transition.duration.fast};
border: none;
outline: ${({ theme, active, highlight }) => (active && highlight ? `1px solid ${theme.accentAction}` : 'none')};
:hover {
cursor: pointer;
background-color: ${({ theme, active }) => (active ? theme.accentActiveSoft : theme.backgroundModule)};
opacity: ${({ theme, active }) => (active ? theme.opacity.hover : 1)};
}
:focus {
background-color: ${({ theme, active }) => (active ? theme.accentActiveSoft : theme.backgroundInteractive)};
}
`
export default FilterOption

View File

@@ -10,6 +10,7 @@ import styled, { useTheme } from 'styled-components/macro'
import { MEDIUM_MEDIA_BREAKPOINT } from '../constants'
import { filterNetworkAtom } from '../state'
import FilterOption from './FilterOption'
const NETWORKS = [
SupportedChainId.MAINNET,
@@ -28,7 +29,6 @@ const InternalMenuItem = styled.div`
text-decoration: none;
}
`
const InternalLinkMenuItem = styled(InternalMenuItem)`
display: flex;
align-items: center;
@@ -59,36 +59,6 @@ const MenuTimeFlyout = styled.span`
z-index: 100;
left: 0px;
`
const StyledMenuButton = styled.button<{ open: boolean }>`
width: 100%;
height: 100%;
color: ${({ theme, open }) => (open ? theme.blue200 : theme.textPrimary)};
border: none;
background-color: ${({ theme, open }) => (open ? theme.accentActiveSoft : theme.backgroundInteractive)};
margin: 0;
padding: 6px 12px 6px 12px;
border-radius: 12px;
font-size: 16px;
line-height: 24px;
font-weight: 400;
:hover {
cursor: pointer;
outline: none;
border: none;
background-color: ${({ theme, open }) => (open ? theme.accentActiveSoft : theme.backgroundModule)};
}
:focus {
background-color: ${({ theme, open }) => (open ? theme.accentActiveSoft : theme.backgroundInteractive)};
border: none;
outline: none;
}
svg {
margin-top: 2px;
}
`
const StyledMenu = styled.div`
display: flex;
justify-content: center;
@@ -96,26 +66,24 @@ const StyledMenu = styled.div`
position: relative;
border: none;
text-align: left;
width: 160px;
@media only screen and (max-width: ${MEDIUM_MEDIA_BREAKPOINT}) {
flex: 1;
}
`
const StyledMenuContent = styled.div`
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
border: none;
width: 100%;
font-weight: 600;
vertical-align: middle;
`
const Chevron = styled.span<{ open: boolean }>`
padding-top: 1px;
color: ${({ open, theme }) => (open ? theme.blue200 : theme.textSecondary)};
color: ${({ open, theme }) => (open ? theme.accentActive : theme.textSecondary)};
`
const NetworkLabel = styled.div`
display: flex;
@@ -143,16 +111,20 @@ export default function NetworkFilter() {
return (
<StyledMenu ref={node}>
<StyledMenuButton onClick={toggleMenu} aria-label={`networkFilter`} open={open}>
<FilterOption onClick={toggleMenu} aria-label={`networkFilter`} active={open}>
<StyledMenuContent>
<NetworkLabel>
<Logo src={circleLogoUrl ?? logoUrl} /> {label}
</NetworkLabel>
<Chevron open={open}>
{open ? <ChevronUp size={15} viewBox="0 0 24 20" /> : <ChevronDown size={15} viewBox="0 0 24 20" />}
{open ? (
<ChevronUp width={20} height={15} viewBox="0 0 24 20" />
) : (
<ChevronDown width={20} height={15} viewBox="0 0 24 20" />
)}
</Chevron>
</StyledMenuContent>
</StyledMenuButton>
</FilterOption>
{open && (
<MenuTimeFlyout>
{NETWORKS.map((network) => (

View File

@@ -1,7 +1,9 @@
import { Trans } from '@lingui/macro'
import searchIcon from 'assets/svg/search.svg'
import xIcon from 'assets/svg/x.svg'
import { useAtom } from 'jotai'
import useDebounce from 'hooks/useDebounce'
import { useUpdateAtom } from 'jotai/utils'
import { useEffect, useState } from 'react'
import styled from 'styled-components/macro'
import { MEDIUM_MEDIA_BREAKPOINT } from '../constants'
@@ -25,6 +27,7 @@ const SearchInput = styled.input`
font-size: 16px;
padding-left: 40px;
color: ${({ theme }) => theme.textSecondary};
transition-duration: ${({ theme }) => theme.transition.duration.fast};
:hover {
background-color: ${({ theme }) => theme.backgroundSurface};
@@ -56,7 +59,14 @@ const SearchInput = styled.input`
`
export default function SearchBar() {
const [filterString, setFilterString] = useAtom(filterStringAtom)
const [localFilterString, setLocalFilterString] = useState('')
const setFilterString = useUpdateAtom(filterStringAtom)
const debouncedLocalFilterString = useDebounce(localFilterString, 300)
useEffect(() => {
setFilterString(debouncedLocalFilterString)
}, [debouncedLocalFilterString, setFilterString])
return (
<SearchBarContainer>
<Trans
@@ -66,8 +76,8 @@ export default function SearchBar() {
placeholder={`${translation}`}
id="searchBar"
autoComplete="off"
value={filterString}
onChange={({ target: { value } }) => setFilterString(value)}
value={localFilterString}
onChange={({ target: { value } }) => setLocalFilterString(value)}
/>
)}
>

View File

@@ -1,4 +1,4 @@
import { TimePeriod } from 'graphql/data/TopTokenQuery'
import { TimePeriod } from 'graphql/data/Token'
import { useOnClickOutside } from 'hooks/useOnClickOutside'
import { useAtom } from 'jotai'
import { useRef } from 'react'
@@ -9,6 +9,7 @@ import styled, { useTheme } from 'styled-components/macro'
import { MOBILE_MEDIA_BREAKPOINT, SMALL_MEDIA_BREAKPOINT } from '../constants'
import { filterTimeAtom } from '../state'
import FilterOption from './FilterOption'
export const DISPLAYS: Record<TimePeriod, string> = {
[TimePeriod.HOUR]: '1H',
@@ -39,7 +40,6 @@ const InternalMenuItem = styled.div`
text-decoration: none;
}
`
const InternalLinkMenuItem = styled(InternalMenuItem)`
display: flex;
flex-direction: row;
@@ -76,36 +76,6 @@ const MenuTimeFlyout = styled.span`
left: unset;
}
`
const StyledMenuButton = styled.button<{ open: boolean }>`
width: 100%;
height: 100%;
border: none;
color: ${({ theme, open }) => (open ? theme.blue200 : theme.textPrimary)};
margin: 0;
background-color: ${({ theme, open }) => (open ? theme.accentActiveSoft : theme.backgroundInteractive)};
padding: 6px 12px 6px 12px;
border-radius: 12px;
font-size: 16px;
line-height: 24px;
font-weight: 600;
:hover {
cursor: pointer;
border: none;
outline: none;
background-color: ${({ theme, open }) => (open ? theme.accentActiveSoft : theme.backgroundModule)};
}
:focus {
background-color: ${({ theme, open }) => (open ? theme.accentActiveSoft : theme.backgroundInteractive)};
border: none;
outline: none;
}
svg {
margin-top: 2px;
}
`
const StyledMenu = styled.div`
display: flex;
justify-content: center;
@@ -113,25 +83,23 @@ const StyledMenu = styled.div`
position: relative;
border: none;
text-align: left;
width: 80px;
@media only screen and (max-width: ${MOBILE_MEDIA_BREAKPOINT}) {
width: 72px;
}
`
const StyledMenuContent = styled.div`
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
border: none;
width: 100%;
vertical-align: middle;
`
const Chevron = styled.span<{ open: boolean }>`
padding-top: 1px;
color: ${({ open, theme }) => (open ? theme.blue200 : theme.textSecondary)};
color: ${({ open, theme }) => (open ? theme.accentActive : theme.textSecondary)};
`
// TODO: change this to reflect data pipeline
@@ -145,14 +113,18 @@ export default function TimeSelector() {
return (
<StyledMenu ref={node}>
<StyledMenuButton onClick={toggleMenu} aria-label={`timeSelector`} open={open}>
<FilterOption onClick={toggleMenu} aria-label={`timeSelector`} active={open}>
<StyledMenuContent>
{DISPLAYS[activeTime]}
<Chevron open={open}>
{open ? <ChevronUp size={15} viewBox="0 0 24 20" /> : <ChevronDown size={15} viewBox="0 0 24 20" />}
{open ? (
<ChevronUp width={20} height={15} viewBox="0 0 24 20" />
) : (
<ChevronDown width={20} height={15} viewBox="0 0 24 20" />
)}
</Chevron>
</StyledMenuContent>
</StyledMenuButton>
</FilterOption>
{open && (
<MenuTimeFlyout>
{ORDERED_TIMES.map((time) => (

View File

@@ -5,14 +5,14 @@ import { EventName } from 'components/AmplitudeAnalytics/constants'
import SparklineChart from 'components/Charts/SparklineChart'
import CurrencyLogo from 'components/CurrencyLogo'
import { getChainInfo } from 'constants/chainInfo'
import { TimePeriod, TokenData } from 'graphql/data/TopTokenQuery'
import { getDurationDetails, SingleTokenData, TimePeriod } from 'graphql/data/Token'
import { useCurrency } from 'hooks/Tokens'
import { useAtom } from 'jotai'
import { useAtomValue } from 'jotai/utils'
import { ReactNode } from 'react'
import { ArrowDown, ArrowUp, Heart } from 'react-feather'
import { Link } from 'react-router-dom'
import styled, { css, useTheme } from 'styled-components/macro'
import { ClickableStyle } from 'theme'
import { formatDollarAmount } from 'utils/formatDollarAmt'
import {
@@ -23,12 +23,12 @@ import {
} from '../constants'
import { LoadingBubble } from '../loading'
import {
favoritesAtom,
filterNetworkAtom,
filterStringAtom,
filterTimeAtom,
sortCategoryAtom,
sortDirectionAtom,
useIsFavorited,
useSetSortCategory,
useToggleFavorite,
} from '../state'
@@ -60,6 +60,7 @@ const StyledTokenRow = styled.div<{ first?: boolean; last?: boolean; loading?: b
},
}) => css`background-color ${duration.medium} ${timing.ease}`};
width: 100%;
transition-duration: ${({ theme }) => theme.transition.duration.fast};
&:hover {
${({ loading, theme }) =>
@@ -109,6 +110,14 @@ export const ClickFavorited = styled.span`
}
`
export const FavoriteIcon = styled(Heart)<{ isFavorited: boolean }>`
${ClickableStyle}
height: 22px;
width: 24px;
color: ${({ isFavorited, theme }) => (isFavorited ? theme.accentAction : theme.textSecondary)};
fill: ${({ isFavorited, theme }) => (isFavorited ? theme.accentAction : 'transparent')};
`
const ClickableContent = styled.div`
display: flex;
text-decoration: none;
@@ -149,10 +158,11 @@ const StyledHeaderRow = styled(StyledTokenRow)`
justify-content: space-between;
}
`
const ListNumberCell = styled(Cell)`
const ListNumberCell = styled(Cell)<{ header: boolean }>`
color: ${({ theme }) => theme.textSecondary};
min-width: 32px;
height: 48px;
height: ${({ header }) => (header ? '48px' : '60px')};
@media only screen and (max-width: ${SMALL_MEDIA_BREAKPOINT}) {
display: none;
@@ -215,15 +225,12 @@ const SortArrowCell = styled(Cell)`
`
const HeaderCellWrapper = styled.span<{ onClick?: () => void }>`
align-items: center;
${ClickableStyle}
cursor: ${({ onClick }) => (onClick ? 'pointer' : 'unset')};
display: flex;
height: 100%;
justify-content: flex-end;
width: 100%;
&:hover {
opacity: 60%;
}
`
const SparkLineCell = styled(Cell)`
padding: 0px 24px;
@@ -389,7 +396,7 @@ export function TokenRow({
}) {
const rowCells = (
<>
<ListNumberCell>{listNumber}</ListNumberCell>
<ListNumberCell header={header}>{listNumber}</ListNumberCell>
<NameCell>{tokenInfo}</NameCell>
<PriceCell sortable={header}>{price}</PriceCell>
<PercentChangeCell sortable={header}>{percentChange}</PercentChangeCell>
@@ -445,31 +452,29 @@ export function LoadingRow() {
/* Loaded State: row component with token information */
export default function LoadedRow({
tokenAddress,
tokenListIndex,
tokenListLength,
tokenData,
timePeriod,
}: {
tokenAddress: string
tokenListIndex: number
tokenListLength: number
tokenData: TokenData
tokenData: SingleTokenData
timePeriod: TimePeriod
}) {
const tokenAddress = tokenData?.tokens?.[0].address
const currency = useCurrency(tokenAddress)
const tokenName = tokenData.name
const tokenSymbol = tokenData.symbol
const theme = useTheme()
const [favoriteTokens] = useAtom(favoritesAtom)
const isFavorited = favoriteTokens.includes(tokenAddress)
const tokenName = tokenData?.name
const tokenSymbol = tokenData?.tokens?.[0].symbol
const isFavorited = useIsFavorited(tokenAddress)
const toggleFavorite = useToggleFavorite(tokenAddress)
const filterString = useAtomValue(filterStringAtom)
const filterNetwork = useAtomValue(filterNetworkAtom)
const L2Icon = getChainInfo(filterNetwork).circleLogoUrl
const delta = tokenData.percentChange?.[timePeriod]?.value
const arrow = delta ? getDeltaArrow(delta) : null
const formattedDelta = delta ? formatDelta(delta) : null
const tokenDetails = tokenData?.markets?.[0]
const { volume, pricePercentChange } = getDurationDetails(tokenData, timePeriod)
const arrow = pricePercentChange ? getDeltaArrow(pricePercentChange) : null
const formattedDelta = pricePercentChange ? formatDelta(pricePercentChange) : null
const exploreTokenSelectedEventProperties = {
chain_id: filterNetwork,
@@ -481,7 +486,6 @@ export default function LoadedRow({
search_token_address_input: filterString,
}
const heartColor = isFavorited ? theme.accentActive : undefined
// TODO: currency logo sizing mobile (32px) vs. desktop (24px)
return (
<StyledLink
@@ -497,14 +501,14 @@ export default function LoadedRow({
toggleFavorite()
}}
>
<Heart size={18} color={heartColor} fill={heartColor} />
<FavoriteIcon isFavorited={isFavorited} />
</ClickFavorited>
}
listNumber={tokenListIndex + 1}
tokenInfo={
<ClickableName>
<LogoContainer>
<CurrencyLogo currency={currency} />
<CurrencyLogo currency={currency} symbol={tokenSymbol} />
<L2NetworkLogo networkUrl={L2Icon} />
</LogoContainer>
<TokenInfoCell>
@@ -516,7 +520,7 @@ export default function LoadedRow({
price={
<ClickableContent>
<PriceInfoCell>
{tokenData.price?.value ? formatDollarAmount(tokenData.price?.value) : '-'}
{tokenDetails?.price?.value ? formatDollarAmount(tokenDetails?.price?.value) : '-'}
<PercentChangeInfoCell>
{formattedDelta}
{arrow}
@@ -532,19 +536,23 @@ export default function LoadedRow({
}
marketCap={
<ClickableContent>
{tokenData.marketCap?.value ? formatDollarAmount(tokenData.marketCap?.value) : '-'}
</ClickableContent>
}
volume={
<ClickableContent>
{tokenData.volume?.[timePeriod]?.value
? formatDollarAmount(tokenData.volume?.[timePeriod]?.value ?? undefined)
: '-'}
{tokenDetails?.marketCap?.value ? formatDollarAmount(tokenDetails?.marketCap?.value) : '-'}
</ClickableContent>
}
volume={<ClickableContent>{volume ? formatDollarAmount(volume ?? undefined) : '-'}</ClickableContent>}
sparkLine={
<SparkLine>
<ParentSize>{({ width, height }) => <SparklineChart width={width} height={height} />}</ParentSize>
<ParentSize>
{({ width, height }) => (
<SparklineChart
width={width}
height={height}
tokenData={tokenData}
pricePercentChange={pricePercentChange}
timePeriod={timePeriod}
/>
)}
</ParentSize>
</SparkLine>
}
first={tokenListIndex === 0}

View File

@@ -7,7 +7,9 @@ import {
sortCategoryAtom,
sortDirectionAtom,
} from 'components/Tokens/state'
import { TimePeriod, TokenData } from 'graphql/data/TopTokenQuery'
import { TokenTopQuery$data } from 'graphql/data/__generated__/TokenTopQuery.graphql'
import { getDurationDetails, SingleTokenData, useTopTokenQuery } from 'graphql/data/Token'
import { TimePeriod } from 'graphql/data/Token'
import { useAtomValue } from 'jotai/utils'
import { ReactNode, Suspense, useCallback, useMemo } from 'react'
import { AlertTriangle } from 'react-feather'
@@ -47,33 +49,37 @@ const TokenRowsContainer = styled.div`
width: 100%;
`
function useFilteredTokens(tokens: TokenData[] | undefined) {
function useFilteredTokens(data: TokenTopQuery$data): SingleTokenData[] | undefined {
const filterString = useAtomValue(filterStringAtom)
const favoriteTokenAddresses = useAtomValue(favoritesAtom)
const favorites = useAtomValue(favoritesAtom)
const showFavorites = useAtomValue(showFavoritesAtom)
const shownTokens =
showFavorites && tokens ? tokens.filter((token) => favoriteTokenAddresses.includes(token.address)) : tokens
return useMemo(
() =>
(shownTokens ?? []).filter((token) => {
if (!token.address) {
return false
}
if (!filterString) {
return true
}
const lowercaseFilterString = filterString.toLowerCase()
const addressIncludesFilterString = token?.address?.toLowerCase().includes(lowercaseFilterString)
const nameIncludesFilterString = token?.name?.toLowerCase().includes(lowercaseFilterString)
const symbolIncludesFilterString = token?.symbol?.toLowerCase().includes(lowercaseFilterString)
return nameIncludesFilterString || symbolIncludesFilterString || addressIncludesFilterString
}),
[shownTokens, filterString]
data.topTokenProjects
?.filter(
(token) => !showFavorites || (token?.tokens?.[0].address && favorites.includes(token?.tokens?.[0].address))
)
.filter((token) => {
const tokenInfo = token?.tokens?.[0]
const address = tokenInfo?.address
if (!address) {
return false
} else if (!filterString) {
return true
} else {
const lowercaseFilterString = filterString.toLowerCase()
const addressIncludesFilterString = address?.toLowerCase().includes(lowercaseFilterString)
const nameIncludesFilterString = token?.name?.toLowerCase().includes(lowercaseFilterString)
const symbolIncludesFilterString = tokenInfo?.symbol?.toLowerCase().includes(lowercaseFilterString)
return nameIncludesFilterString || symbolIncludesFilterString || addressIncludesFilterString
}
}),
[data.topTokenProjects, favorites, filterString, showFavorites]
)
}
function useSortedTokens(tokenData: TokenData[] | null) {
function useSortedTokens(tokenData: SingleTokenData[] | undefined) {
const sortCategory = useAtomValue(sortCategoryAtom)
const sortDirection = useAtomValue(sortDirectionAtom)
const timePeriod = useAtomValue<TimePeriod>(filterTimeAtom)
@@ -103,22 +109,25 @@ function useSortedTokens(tokenData: TokenData[] | null) {
}
let a: number | null | undefined
let b: number | null | undefined
const { volume: aVolume, pricePercentChange: aChange } = getDurationDetails(token1, timePeriod)
const { volume: bVolume, pricePercentChange: bChange } = getDurationDetails(token2, timePeriod)
switch (sortCategory) {
case Category.marketCap:
a = token1.marketCap?.value
b = token2.marketCap?.value
a = token1.markets?.[0]?.marketCap?.value
b = token2.markets?.[0]?.marketCap?.value
break
case Category.price:
a = token1.price?.value
b = token2.price?.value
a = token1.markets?.[0]?.price?.value
b = token2.markets?.[0]?.price?.value
break
case Category.volume:
a = token1.volume?.[timePeriod]?.value
b = token2.volume?.[timePeriod]?.value
a = aVolume
b = bVolume
break
case Category.percentChange:
a = token1.percentChange?.[timePeriod]?.value
b = token2.percentChange?.[timePeriod]?.value
a = aChange
b = bChange
break
}
return sortFn(a, b)
@@ -149,14 +158,15 @@ export function LoadingTokenTable() {
)
}
export default function TokenTable({ data }: { data: TokenData[] | undefined }) {
export default function TokenTable() {
const showFavorites = useAtomValue<boolean>(showFavoritesAtom)
const timePeriod = useAtomValue<TimePeriod>(filterTimeAtom)
const filteredTokens = useFilteredTokens(data)
const topTokens = useTopTokenQuery(1, timePeriod)
const filteredTokens = useFilteredTokens(topTokens)
const sortedFilteredTokens = useSortedTokens(filteredTokens)
/* loading and error state */
if (data === null) {
if (topTokens === null) {
return (
<NoTokensState
message={
@@ -184,8 +194,7 @@ export default function TokenTable({ data }: { data: TokenData[] | undefined })
<TokenRowsContainer>
{sortedFilteredTokens?.map((token, index) => (
<LoadedRow
key={token.address}
tokenAddress={token.address}
key={token?.name}
tokenListIndex={index}
tokenListLength={sortedFilteredTokens.length}
tokenData={token}

View File

@@ -32,7 +32,7 @@ const PopupContainer = styled.div<{ show: boolean }>`
theme: {
transition: { duration, timing },
},
}) => `${duration.slow}ms opacity ${timing.in}`};
}) => `${duration.slow} opacity ${timing.in}`};
`
const Header = styled.div`
display: flex;

View File

@@ -1,8 +1,8 @@
import { SupportedChainId } from 'constants/chains'
import { TimePeriod } from 'graphql/data/TopTokenQuery'
import { TimePeriod } from 'graphql/data/Token'
import { atom, useAtom } from 'jotai'
import { atomWithReset, atomWithStorage } from 'jotai/utils'
import { useCallback } from 'react'
import { atomWithReset, atomWithStorage, useAtomValue } from 'jotai/utils'
import { useCallback, useMemo } from 'react'
import { Category, SortDirection } from './types'
@@ -15,17 +15,18 @@ export const sortCategoryAtom = atom<Category>(Category.marketCap)
export const sortDirectionAtom = atom<SortDirection>(SortDirection.decreasing)
/* for favoriting tokens */
export function useToggleFavorite(tokenAddress: string) {
export function useToggleFavorite(tokenAddress: string | undefined | null) {
const [favoriteTokens, updateFavoriteTokens] = useAtom(favoritesAtom)
return useCallback(() => {
if (!tokenAddress) return
let updatedFavoriteTokens
if (favoriteTokens.includes(tokenAddress)) {
if (favoriteTokens.includes(tokenAddress.toLocaleLowerCase())) {
updatedFavoriteTokens = favoriteTokens.filter((address: string) => {
return address !== tokenAddress
return address !== tokenAddress.toLocaleLowerCase()
})
} else {
updatedFavoriteTokens = [...favoriteTokens, tokenAddress]
updatedFavoriteTokens = [...favoriteTokens, tokenAddress.toLocaleLowerCase()]
}
updateFavoriteTokens(updatedFavoriteTokens)
}, [favoriteTokens, tokenAddress, updateFavoriteTokens])
@@ -47,3 +48,12 @@ export function useSetSortCategory(category: Category) {
}
}, [category, sortCategory, setSortCategory, sortDirection, setDirectionCategory])
}
export function useIsFavorited(tokenAddress: string | null | undefined) {
const favoritedTokens = useAtomValue<string[]>(favoritesAtom)
return useMemo(
() => (tokenAddress ? favoritedTokens.includes(tokenAddress.toLocaleLowerCase()) : false),
[favoritedTokens, tokenAddress]
)
}

View File

@@ -132,7 +132,12 @@ const AuthenticatedHeader = () => {
<IconContainer>
<IconButton onClick={copy} Icon={Copy} text={isCopied ? <Trans>Copied!</Trans> : <Trans>Copy</Trans>} />
<IconButton href={`${explorer}address/${account}`} Icon={ExternalLink} text={<Trans>Explore</Trans>} />
<IconButton onClick={disconnect} Icon={Power} text={<Trans>Disconnect</Trans>} />
<IconButton
dataTestId="wallet-disconnect"
onClick={disconnect}
Icon={Power}
text={<Trans>Disconnect</Trans>}
/>
</IconContainer>
</HeaderWrapper>
<Column>

View File

@@ -27,7 +27,7 @@ const ConnectButton = styled(ButtonPrimary)`
`
const Divider = styled.div`
border: 1px solid ${({ theme }) => theme.backgroundOutline};
border-bottom: 1px solid ${({ theme }) => theme.backgroundOutline};
margin-top: 16px;
margin-bottom: 16px;
`
@@ -55,7 +55,7 @@ const ToggleMenuItem = styled.button`
theme: {
transition: { duration, timing },
},
}) => `${duration.fast}ms all ${timing.in}`};
}) => `${duration.fast} all ${timing.in}`};
}
`
@@ -114,11 +114,13 @@ const WalletDropdown = ({ setMenu }: { setMenu: (state: MenuState) => void }) =>
{isAuthenticated ? (
<AuthenticatedHeader />
) : (
<ConnectButton onClick={toggleWalletModal}>Connect wallet</ConnectButton>
<ConnectButton data-testid="wallet-connect-wallet" onClick={toggleWalletModal}>
Connect wallet
</ConnectButton>
)}
<Divider />
{isAuthenticated && (
<ToggleMenuItem onClick={() => setMenu(MenuState.TRANSACTIONS)}>
<ToggleMenuItem data-testid="wallet-transactions" onClick={() => setMenu(MenuState.TRANSACTIONS)}>
<DefaultText>
<Trans>Transactions</Trans>{' '}
{pendingTransactions.length > 0 && (
@@ -132,7 +134,7 @@ const WalletDropdown = ({ setMenu }: { setMenu: (state: MenuState) => void }) =>
</IconWrap>
</ToggleMenuItem>
)}
<ToggleMenuItem onClick={() => setMenu(MenuState.LANGUAGE)}>
<ToggleMenuItem data-testid="wallet-select-language" onClick={() => setMenu(MenuState.LANGUAGE)}>
<DefaultText>
<Trans>Language</Trans>
</DefaultText>
@@ -145,7 +147,7 @@ const WalletDropdown = ({ setMenu }: { setMenu: (state: MenuState) => void }) =>
</IconWrap>
</FlexContainer>
</ToggleMenuItem>
<ToggleMenuItem onClick={toggleDarkMode}>
<ToggleMenuItem data-testid="wallet-select-theme" onClick={toggleDarkMode}>
<DefaultText>{darkMode ? <Trans> Light theme</Trans> : <Trans>Dark theme</Trans>}</DefaultText>
<IconWrap>{darkMode ? <Sun size={16} /> : <Moon size={16} />}</IconWrap>
</ToggleMenuItem>

View File

@@ -28,7 +28,7 @@ const IconStyles = css`
theme: {
transition: { duration, timing },
},
}) => `${duration.fast}ms background-color ${timing.in}`};
}) => `${duration.fast} background-color ${timing.in}`};
${IconHoverText} {
opacity: 1;
@@ -64,18 +64,19 @@ interface IconButtonProps {
Icon: Icon
onClick?: () => void
href?: string
dataTestId?: string
}
const IconButton = ({ Icon, onClick, text, href }: IconButtonProps) => {
const IconButton = ({ Icon, onClick, text, href, dataTestId }: IconButtonProps) => {
return href ? (
<IconBlockLink href={href} target="_blank">
<IconBlockLink data-testId={dataTestId} href={href} target="_blank">
<IconWrapper>
<Icon strokeWidth={1.5} size={16} />
<IconHoverText>{text}</IconHoverText>
</IconWrapper>
</IconBlockLink>
) : (
<IconBlockButton onClick={onClick}>
<IconBlockButton data-testId={dataTestId} onClick={onClick}>
<IconWrapper>
<Icon strokeWidth={1.5} size={16} />
<IconHoverText>{text}</IconHoverText>

View File

@@ -33,7 +33,7 @@ const InternalLinkMenuItem = styled(InternalMenuItem)`
theme: {
transition: { duration, timing },
},
}) => `${duration.fast}ms background-color ${timing.in}`};
}) => `${duration.fast} background-color ${timing.in}`};
}
`
@@ -45,7 +45,7 @@ function LanguageMenuItem({ locale, isActive }: { locale: SupportedLocale; isAct
return (
<InternalLinkMenuItem onClick={onClick} to={to}>
<Text fontSize={16} fontWeight={400} lineHeight="24px">
<Text data-testid="wallet-language-item" fontSize={16} fontWeight={400} lineHeight="24px">
{LOCALE_LABEL[locale]}
</Text>
{isActive && <Check color={theme.accentActive} opacity={1} size={20} />}

View File

@@ -42,12 +42,12 @@ const ClearAll = styled.div`
margin-bottom: auto;
:hover {
opacity: 0.6;
opacity: ${({ theme }) => theme.opacity.hover};
transition: ${({
theme: {
transition: { duration, timing },
},
}) => `${duration.fast}ms opacity ${timing.in}`};
}) => `${duration.fast} opacity ${timing.in}`};
}
`
@@ -60,7 +60,7 @@ const StyledChevron = styled(ChevronLeft)`
theme: {
transition: { duration, timing },
},
}) => `${duration.fast}ms color ${timing.in}`};
}) => `${duration.fast} color ${timing.in}`};
}
`
@@ -101,8 +101,8 @@ export const SlideOutMenu = ({
<Menu>
<BackSection>
<BackSectionContainer>
<StyledChevron onClick={onClose} size={24} />
<Header>{title}</Header>
<StyledChevron data-testid="wallet-back" onClick={onClose} size={24} />
<Header data-testid="wallet-header">{title}</Header>
{onClear && <ClearAll onClick={onClear}>Clear All</ClearAll>}
</BackSectionContainer>
</BackSection>

View File

@@ -158,7 +158,7 @@ export const TransactionHistoryMenu = ({ onClose }: { onClose: () => void }) =>
))}
</>
) : (
<EmptyTransaction>
<EmptyTransaction data-testid="wallet-empty-transaction-text">
<Trans>Your transactions will appear here</Trans>
</EmptyTransaction>
)}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import styled from 'styled-components/macro'
import { Z_INDEX } from 'theme'
import { Z_INDEX } from 'theme/zIndex'
import { useModalIsOpen } from '../../state/application/hooks'
import { ApplicationModal } from '../../state/application/reducer'
@@ -37,8 +37,8 @@ export enum MenuState {
}
const WalletDropdownWrapper = styled.div`
position: absolute;
top: 65px;
position: fixed;
top: 72px;
right: 20px;
z-index: ${Z_INDEX.dropdown};

View File

@@ -35,7 +35,7 @@ const CloseIcon = styled.div`
top: 14px;
&:hover {
cursor: pointer;
opacity: 0.6;
opacity: ${({ theme }) => theme.opacity.hover};
}
`
@@ -333,7 +333,7 @@ export default function WalletModal({
return (
<UpperSection>
<CloseIcon onClick={toggleWalletModal}>
<CloseIcon data-testid="wallet-modal-close" onClick={toggleWalletModal}>
<CloseColor />
</CloseIcon>
{headerRow}
@@ -363,7 +363,9 @@ export default function WalletModal({
maxHeight={90}
redesignFlag={redesignFlagEnabled}
>
<Wrapper redesignFlag={redesignFlagEnabled}>{getModalContent()}</Wrapper>
<Wrapper data-testid="wallet-modal" redesignFlag={redesignFlagEnabled}>
{getModalContent()}
</Wrapper>
</Modal>
)
}

View File

@@ -1,10 +1,13 @@
import { Web3ReactHooks, Web3ReactProvider } from '@web3-react/core'
import { SupportedChainId } from '@uniswap/widgets'
import { useWeb3React, Web3ReactHooks, Web3ReactProvider } from '@web3-react/core'
import { Connector } from '@web3-react/types'
import { Connection } from 'connection'
import { getConnectionName } from 'connection/utils'
import { RPC_PROVIDERS } from 'constants/providers'
import { TraceJsonRpcVariant, useTraceJsonRpcFlag } from 'featureFlags/flags/traceJsonRpc'
import useEagerlyConnect from 'hooks/useEagerlyConnect'
import useOrderedConnections from 'hooks/useOrderedConnections'
import { ReactNode, useMemo } from 'react'
import { ReactNode, useEffect, useMemo } from 'react'
export default function Web3Provider({ children }: { children: ReactNode }) {
useEagerlyConnect()
@@ -15,7 +18,37 @@ export default function Web3Provider({ children }: { children: ReactNode }) {
return (
<Web3ReactProvider connectors={connectors} key={key}>
<Tracer />
{children}
</Web3ReactProvider>
)
}
function Tracer() {
const { chainId, provider } = useWeb3React()
const networkProvider = RPC_PROVIDERS[(chainId || SupportedChainId.MAINNET) as SupportedChainId]
const shouldTrace = useTraceJsonRpcFlag() === TraceJsonRpcVariant.Enabled
useEffect(() => {
if (shouldTrace) {
provider?.on('debug', trace)
if (provider !== networkProvider) {
networkProvider.on('debug', trace)
}
}
return () => {
provider?.off('debug', trace)
networkProvider.off('debug', trace)
}
}, [networkProvider, provider, shouldTrace])
return null
}
function trace(event: any) {
if (event.action !== 'request') return
const { method, id, params } = event.request
console.groupCollapsed(method, id)
console.debug(params)
console.groupEnd()
}

View File

@@ -3,6 +3,7 @@ import { t, Trans } from '@lingui/macro'
import { useWeb3React } from '@web3-react/core'
import { ElementName, Event, EventName } from 'components/AmplitudeAnalytics/constants'
import { TraceEvent } from 'components/AmplitudeAnalytics/TraceEvent'
import { StyledChevronDown, StyledChevronUp } from 'components/Icons'
import WalletDropdown from 'components/WalletDropdown'
import { getConnection } from 'connection/utils'
import { NavBarVariant, useNavBarFlag } from 'featureFlags/flags/navBar'
@@ -10,10 +11,10 @@ import { Portal } from 'nft/components/common/Portal'
import { getIsValidSwapQuote } from 'pages/Swap'
import { darken } from 'polished'
import { useMemo, useRef } from 'react'
import { AlertTriangle, ChevronDown, ChevronUp } from 'react-feather'
import { AlertTriangle } from 'react-feather'
import { useAppSelector } from 'state/hooks'
import { useDerivedSwapInfo } from 'state/swap/hooks'
import styled, { css } from 'styled-components/macro'
import styled, { css, useTheme } from 'styled-components/macro'
import { useOnClickOutside } from '../../hooks/useOnClickOutside'
import { useHasSocks } from '../../hooks/useSocksBalance'
@@ -67,7 +68,7 @@ const Web3StatusConnectNavbar = styled.button<{ faded?: boolean }>`
border-radius: 12px;
border: none;
cursor: pointer;
padding: 10px 12px;
padding: 8px 12px;
:hover,
:active,
@@ -153,6 +154,7 @@ function Sock() {
const VerticalDivider = styled.div`
height: 20px;
margin: 0px 4px;
width: 1px;
background-color: ${({ theme }) => theme.accentAction};
`
@@ -169,22 +171,7 @@ const StyledConnect = styled.div`
theme: {
transition: { duration, timing },
},
}) => `${duration.fast}ms color ${timing.in}`};
}
`
const StyledChevron = styled.span`
color: ${({ theme }) => theme.accentAction};
height: 24px;
margin-left: 4px;
&:hover {
color: ${({ theme }) => theme.accentActionSoft};
transition: ${({
theme: {
transition: { duration, timing },
},
}) => `${duration.fast}ms color ${timing.in}`};
}) => `${duration.fast} color ${timing.in}`};
}
`
@@ -196,7 +183,8 @@ function Web3StatusInner() {
inputError: swapInputError,
} = useDerivedSwapInfo()
const validSwapQuote = getIsValidSwapQuote(trade, tradeState, swapInputError)
const navbarFlag = useNavBarFlag()
const navbarFlagEnabled = useNavBarFlag() === NavBarVariant.Enabled
const theme = useTheme()
const toggleWalletDropdown = useToggleWalletDropdown()
const toggleWalletModal = useToggleWalletModal()
const walletIsOpen = useIsOpen()
@@ -214,7 +202,7 @@ function Web3StatusInner() {
const hasPendingTransactions = !!pending.length
const hasSocks = useHasSocks()
const toggleWallet = navbarFlag === NavBarVariant.Enabled ? toggleWalletDropdown : toggleWalletModal
const toggleWallet = navbarFlagEnabled ? toggleWalletDropdown : toggleWalletModal
if (!chainId) {
return null
@@ -230,6 +218,7 @@ function Web3StatusInner() {
} else if (account) {
return (
<Web3StatusConnected data-testid="web3-status-connected" onClick={toggleWallet} pending={hasPendingTransactions}>
{navbarFlagEnabled && !hasPendingTransactions && <StatusIcon size={24} connectionType={connectionType} />}
{hasPendingTransactions ? (
<RowBetween>
<Text>
@@ -239,11 +228,18 @@ function Web3StatusInner() {
</RowBetween>
) : (
<>
{hasSocks ? <Sock /> : null}
{hasSocks && !navbarFlagEnabled ? <Sock /> : null}
<Text>{ENSName || shortenAddress(account)}</Text>
{navbarFlagEnabled ? (
walletIsOpen ? (
<StyledChevronUp onClick={toggleWalletDropdown} />
) : (
<StyledChevronDown onClick={toggleWalletDropdown} />
)
) : null}
</>
)}
{!hasPendingTransactions && <StatusIcon connectionType={connectionType} />}
{!navbarFlagEnabled && !hasPendingTransactions && <StatusIcon connectionType={connectionType} />}
</Web3StatusConnected>
)
} else {
@@ -254,15 +250,25 @@ function Web3StatusInner() {
properties={{ received_swap_quote: validSwapQuote }}
element={ElementName.CONNECT_WALLET_BUTTON}
>
{navbarFlag === NavBarVariant.Enabled ? (
{navbarFlagEnabled ? (
<Web3StatusConnectNavbar faded={!account}>
<StyledConnect onClick={toggleWalletModal}>
<StyledConnect data-testid="navbar-connect-wallet" onClick={toggleWalletModal}>
<Trans>Connect</Trans>
</StyledConnect>
<VerticalDivider />
<StyledChevron onClick={toggleWalletDropdown}>
{walletIsOpen ? <ChevronUp /> : <ChevronDown />}
</StyledChevron>
{walletIsOpen ? (
<StyledChevronUp
data-testid="navbar-wallet-dropdown"
customColor={theme.accentAction}
onClick={toggleWalletDropdown}
/>
) : (
<StyledChevronDown
data-testid="navbar-wallet-dropdown"
customColor={theme.accentAction}
onClick={toggleWalletDropdown}
/>
)}
</Web3StatusConnectNavbar>
) : (
<Web3StatusConnect onClick={toggleWallet} faded={!account}>

View File

@@ -1,6 +1,6 @@
import { Currency, SwapWidget } from '@uniswap/widgets'
import { Currency, OnReviewSwapClick, SwapWidget } from '@uniswap/widgets'
import { useWeb3React } from '@web3-react/core'
import { RPC_URLS } from 'constants/networks'
import { RPC_PROVIDERS } from 'constants/providers'
import { useActiveLocale } from 'hooks/useActiveLocale'
import { useMemo } from 'react'
import { useIsDarkMode } from 'state/user/hooks'
@@ -16,9 +16,10 @@ const WIDGET_ROUTER_URL = 'https://api.uniswap.org/v1/'
export interface WidgetProps {
defaultToken?: Currency
onReviewSwapClick?: OnReviewSwapClick
}
export default function Widget({ defaultToken }: WidgetProps) {
export default function Widget({ defaultToken, onReviewSwapClick }: WidgetProps) {
const locale = useActiveLocale()
const darkMode = useIsDarkMode()
const theme = useMemo(() => (darkMode ? DARK_THEME : LIGHT_THEME), [darkMode])
@@ -31,12 +32,14 @@ export default function Widget({ defaultToken }: WidgetProps) {
return (
<>
<SwapWidget
disableBranding
hideConnectionUI
jsonRpcUrlMap={RPC_URLS}
jsonRpcUrlMap={RPC_PROVIDERS}
routerUrl={WIDGET_ROUTER_URL}
width={WIDGET_WIDTH}
locale={locale}
theme={theme}
onReviewSwapClick={onReviewSwapClick}
// defaultChainId is excluded - it is always inferred from the passed provider
provider={provider}
{...inputs}

View File

@@ -8,7 +8,7 @@ import { useCallback, useMemo, useState } from 'react'
*/
export function useSyncWidgetInputs(defaultToken?: Currency) {
const [type, setType] = useState(TradeType.EXACT_INPUT)
const [amount, setAmount] = useState<string>()
const [amount, setAmount] = useState('')
const onAmountChange = useCallback((field: Field, amount: string) => {
setType(toTradeType(field))
setAmount(amount)

View File

@@ -1,18 +1,70 @@
import { TransactionReceipt } from '@ethersproject/abstract-provider'
import { TransactionEventHandlers } from '@uniswap/widgets'
import { useMemo } from 'react'
import {
TradeType,
Transaction,
TransactionEventHandlers,
TransactionInfo,
TransactionType as WidgetTransactionType,
} from '@uniswap/widgets'
import { useWeb3React } from '@web3-react/core'
import { useCallback, useMemo } from 'react'
import { useTransactionAdder } from 'state/transactions/hooks'
import {
ExactInputSwapTransactionInfo,
ExactOutputSwapTransactionInfo,
TransactionType as AppTransactionType,
WrapTransactionInfo,
} from 'state/transactions/types'
import { currencyId } from 'utils/currencyId'
/** Integrates the Widget's transactions, showing the widget's transactions in the app. */
export function useSyncWidgetTransactions() {
// TODO(jfrankfurt): Integrate widget transactions with app transaction tracking.
const txHandlers: TransactionEventHandlers = useMemo(
() => ({
onTxSubmit: (hash: string, tx: unknown) => console.log('onTxSubmit'),
onTxSuccess: (hash: string, receipt: TransactionReceipt) => console.log('onTxSuccess'),
onTxFail: (hash: string, receipt: TransactionReceipt) => console.log('onTxFail'),
}),
[]
const { chainId } = useWeb3React()
const addTransaction = useTransactionAdder()
const onTxSubmit = useCallback(
(_hash: string, transaction: Transaction<TransactionInfo>) => {
const { type, response } = transaction.info
if (!type || !response) {
return
} else if (type === WidgetTransactionType.WRAP || type === WidgetTransactionType.UNWRAP) {
const { amount } = transaction.info
addTransaction(response, {
type: AppTransactionType.WRAP,
unwrapped: type === WidgetTransactionType.UNWRAP,
currencyAmountRaw: amount.quotient.toString(),
chainId,
} as WrapTransactionInfo)
} else if (type === WidgetTransactionType.SWAP) {
const { slippageTolerance, trade, tradeType } = transaction.info
const baseTxInfo = {
type: AppTransactionType.SWAP,
tradeType,
inputCurrencyId: currencyId(trade.inputAmount.currency),
outputCurrencyId: currencyId(trade.outputAmount.currency),
}
if (tradeType === TradeType.EXACT_OUTPUT) {
addTransaction(response, {
...baseTxInfo,
maximumInputCurrencyAmountRaw: trade.maximumAmountIn(slippageTolerance).quotient.toString(),
outputCurrencyAmountRaw: trade.outputAmount.quotient.toString(),
expectedInputCurrencyAmountRaw: trade.inputAmount.quotient.toString(),
} as ExactOutputSwapTransactionInfo)
} else {
addTransaction(response, {
...baseTxInfo,
inputCurrencyAmountRaw: trade.inputAmount.quotient.toString(),
expectedOutputCurrencyAmountRaw: trade.outputAmount.quotient.toString(),
minimumOutputCurrencyAmountRaw: trade.minimumAmountOut(slippageTolerance).quotient.toString(),
} as ExactInputSwapTransactionInfo)
}
}
},
[addTransaction, chainId]
)
const txHandlers: TransactionEventHandlers = useMemo(() => ({ onTxSubmit }), [onTxSubmit])
return { transactions: { ...txHandlers } }
}

View File

@@ -1,4 +1,4 @@
import { TransactionResponse } from '@ethersproject/providers'
import type { TransactionResponse } from '@ethersproject/providers'
import { Trans } from '@lingui/macro'
import StakingRewardsJson from '@uniswap/liquidity-staker/build/StakingRewards.json'
import { useWeb3React } from '@web3-react/core'

View File

@@ -1,4 +1,4 @@
import { TransactionResponse } from '@ethersproject/providers'
import type { TransactionResponse } from '@ethersproject/providers'
import { Trans } from '@lingui/macro'
import StakingRewardsJson from '@uniswap/liquidity-staker/build/StakingRewards.json'
import { CurrencyAmount, Token } from '@uniswap/sdk-core'

View File

@@ -1,4 +1,4 @@
import { TransactionResponse } from '@ethersproject/providers'
import type { TransactionResponse } from '@ethersproject/providers'
import { Trans } from '@lingui/macro'
import StakingRewardsJson from '@uniswap/liquidity-staker/build/StakingRewards.json'
import { useWeb3React } from '@web3-react/core'

View File

@@ -1,10 +1,14 @@
import { Trans } from '@lingui/macro'
import { Trade } from '@uniswap/router-sdk'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { Currency, CurrencyAmount, Percent, Token, TradeType } from '@uniswap/sdk-core'
import { sendAnalyticsEvent } from 'components/AmplitudeAnalytics'
import { ModalName } from 'components/AmplitudeAnalytics/constants'
import { EventName } from 'components/AmplitudeAnalytics/constants'
import { Trace } from 'components/AmplitudeAnalytics/Trace'
import { ReactNode, useCallback, useMemo, useState } from 'react'
import { formatPercentInBasisPointsNumber, formatToDecimal, getTokenAddress } from 'components/AmplitudeAnalytics/utils'
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
import { InterfaceTrade } from 'state/routing/types'
import { computeRealizedPriceImpact } from 'utils/prices'
import { tradeMeaningfullyDiffers } from 'utils/tradeMeaningFullyDiffer'
import TransactionConfirmationModal, {
@@ -14,6 +18,27 @@ import TransactionConfirmationModal, {
import SwapModalFooter from './SwapModalFooter'
import SwapModalHeader from './SwapModalHeader'
const formatAnalyticsEventProperties = ({
trade,
txHash,
}: {
trade: InterfaceTrade<Currency, Currency, TradeType>
txHash: string
}) => ({
transaction_hash: txHash,
token_in_address: getTokenAddress(trade.inputAmount.currency),
token_out_address: getTokenAddress(trade.outputAmount.currency),
token_in_symbol: trade.inputAmount.currency.symbol,
token_out_symbol: trade.outputAmount.currency.symbol,
token_in_amount: formatToDecimal(trade.inputAmount, trade.inputAmount.currency.decimals),
token_out_amount: formatToDecimal(trade.outputAmount, trade.outputAmount.currency.decimals),
price_impact_basis_points: formatPercentInBasisPointsNumber(computeRealizedPriceImpact(trade)),
chain_id:
trade.inputAmount.currency.chainId === trade.outputAmount.currency.chainId
? trade.inputAmount.currency.chainId
: undefined,
})
export default function ConfirmSwapModal({
trade,
originalTrade,
@@ -27,6 +52,8 @@ export default function ConfirmSwapModal({
attemptingTxn,
txHash,
swapQuoteReceivedDate,
fiatValueInput,
fiatValueOutput,
}: {
isOpen: boolean
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined
@@ -40,10 +67,13 @@ export default function ConfirmSwapModal({
swapErrorMessage: ReactNode | undefined
onDismiss: () => void
swapQuoteReceivedDate: Date | undefined
fiatValueInput?: CurrencyAmount<Token> | null
fiatValueOutput?: CurrencyAmount<Token> | null
}) {
// shouldLogModalCloseEvent lets the child SwapModalHeader component know when modal has been closed
// and an event triggered by modal closing should be logged.
const [shouldLogModalCloseEvent, setShouldLogModalCloseEvent] = useState(false)
const [lastTxnHashLogged, setLastTxnHashLogged] = useState<string | null>(null)
const showAcceptChanges = useMemo(
() => Boolean(trade && originalTrade && tradeMeaningfullyDiffers(trade, originalTrade)),
[originalTrade, trade]
@@ -78,9 +108,21 @@ export default function ConfirmSwapModal({
disabledConfirm={showAcceptChanges}
swapErrorMessage={swapErrorMessage}
swapQuoteReceivedDate={swapQuoteReceivedDate}
fiatValueInput={fiatValueInput}
fiatValueOutput={fiatValueOutput}
/>
) : null
}, [onConfirm, showAcceptChanges, swapErrorMessage, trade, allowedSlippage, txHash, swapQuoteReceivedDate])
}, [
onConfirm,
showAcceptChanges,
swapErrorMessage,
trade,
allowedSlippage,
txHash,
swapQuoteReceivedDate,
fiatValueInput,
fiatValueOutput,
])
// text to show while loading
const pendingText = (
@@ -105,8 +147,15 @@ export default function ConfirmSwapModal({
[onModalDismiss, modalBottom, modalHeader, swapErrorMessage]
)
useEffect(() => {
if (!attemptingTxn && isOpen && txHash && trade && txHash !== lastTxnHashLogged) {
sendAnalyticsEvent(EventName.SWAP_SIGNED, formatAnalyticsEventProperties({ trade, txHash }))
setLastTxnHashLogged(txHash)
}
}, [attemptingTxn, isOpen, txHash, trade, lastTxnHashLogged])
return (
<Trace modal={ModalName.CONFIRM_SWAP} shouldLogImpression={isOpen}>
<Trace modal={ModalName.CONFIRM_SWAP}>
<TransactionConfirmationModal
isOpen={isOpen}
onDismiss={onModalDismiss}

View File

@@ -1,5 +1,5 @@
import { Trans } from '@lingui/macro'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { Currency, CurrencyAmount, Percent, Token, TradeType } from '@uniswap/sdk-core'
import { ElementName, Event, EventName } from 'components/AmplitudeAnalytics/constants'
import { TraceEvent } from 'components/AmplitudeAnalytics/TraceEvent'
import {
@@ -10,7 +10,6 @@ import {
getDurationUntilTimestampSeconds,
getTokenAddress,
} from 'components/AmplitudeAnalytics/utils'
import { useStablecoinValue } from 'hooks/useStablecoinPrice'
import useTransactionDeadline from 'hooks/useTransactionDeadline'
import { ReactNode } from 'react'
import { Text } from 'rebass'
@@ -30,10 +29,10 @@ interface AnalyticsEventProps {
transactionDeadlineSecondsSinceEpoch: number | undefined
isAutoSlippage: boolean
isAutoRouterApi: boolean
tokenInAmountUsd: string | undefined
tokenOutAmountUsd: string | undefined
swapQuoteReceivedDate: Date | undefined
routes: RoutingDiagramEntry[]
fiatValueInput?: CurrencyAmount<Token> | null
fiatValueOutput?: CurrencyAmount<Token> | null
}
const formatRoutesEventProperties = (routes: RoutingDiagramEntry[]) => {
@@ -70,22 +69,22 @@ const formatAnalyticsEventProperties = ({
transactionDeadlineSecondsSinceEpoch,
isAutoSlippage,
isAutoRouterApi,
tokenInAmountUsd,
tokenOutAmountUsd,
swapQuoteReceivedDate,
routes,
fiatValueInput,
fiatValueOutput,
}: AnalyticsEventProps) => ({
estimated_network_fee_usd: trade.gasUseEstimateUSD ? formatToDecimal(trade.gasUseEstimateUSD, 2) : undefined,
transaction_hash: hash,
transaction_deadline_seconds: getDurationUntilTimestampSeconds(transactionDeadlineSecondsSinceEpoch),
token_in_amount_usd: tokenInAmountUsd ? parseFloat(tokenInAmountUsd) : undefined,
token_out_amount_usd: tokenOutAmountUsd ? parseFloat(tokenOutAmountUsd) : undefined,
token_in_address: getTokenAddress(trade.inputAmount.currency),
token_out_address: getTokenAddress(trade.outputAmount.currency),
token_in_symbol: trade.inputAmount.currency.symbol,
token_out_symbol: trade.outputAmount.currency.symbol,
token_in_amount: formatToDecimal(trade.inputAmount, trade.inputAmount.currency.decimals),
token_out_amount: formatToDecimal(trade.outputAmount, trade.outputAmount.currency.decimals),
token_in_amount_usd: fiatValueInput ? parseFloat(fiatValueInput.toFixed(2)) : undefined,
token_out_amount_usd: fiatValueOutput ? parseFloat(fiatValueOutput.toFixed(2)) : undefined,
price_impact_basis_points: formatPercentInBasisPointsNumber(computeRealizedPriceImpact(trade)),
allowed_slippage_basis_points: formatPercentInBasisPointsNumber(allowedSlippage),
is_auto_router_api: isAutoRouterApi,
@@ -109,6 +108,8 @@ export default function SwapModalFooter({
swapErrorMessage,
disabledConfirm,
swapQuoteReceivedDate,
fiatValueInput,
fiatValueOutput,
}: {
trade: InterfaceTrade<Currency, Currency, TradeType>
hash: string | undefined
@@ -117,12 +118,12 @@ export default function SwapModalFooter({
swapErrorMessage: ReactNode | undefined
disabledConfirm: boolean
swapQuoteReceivedDate: Date | undefined
fiatValueInput?: CurrencyAmount<Token> | null
fiatValueOutput?: CurrencyAmount<Token> | null
}) {
const transactionDeadlineSecondsSinceEpoch = useTransactionDeadline()?.toNumber() // in seconds since epoch
const isAutoSlippage = useUserSlippageTolerance()[0] === 'auto'
const [clientSideRouter] = useClientSideRouter()
const tokenInAmountUsd = useStablecoinValue(trade.inputAmount)?.toFixed(2)
const tokenOutAmountUsd = useStablecoinValue(trade.outputAmount)?.toFixed(2)
const routes = getTokenPath(trade)
return (
@@ -131,7 +132,7 @@ export default function SwapModalFooter({
<TraceEvent
events={[Event.onClick]}
element={ElementName.CONFIRM_SWAP_BUTTON}
name={EventName.SWAP_SUBMITTED}
name={EventName.SWAP_SUBMITTED_BUTTON_CLICKED}
properties={formatAnalyticsEventProperties({
trade,
hash,
@@ -139,10 +140,10 @@ export default function SwapModalFooter({
transactionDeadlineSecondsSinceEpoch,
isAutoSlippage,
isAutoRouterApi: !clientSideRouter,
tokenInAmountUsd,
tokenOutAmountUsd,
swapQuoteReceivedDate,
routes,
fiatValueInput,
fiatValueOutput,
})}
>
<ButtonError

View File

@@ -9,7 +9,8 @@ import Modal from 'components/Modal'
import { AutoRow, RowBetween } from 'components/Row'
import { useState } from 'react'
import styled from 'styled-components/macro'
import { CloseIcon, ExternalLink, ThemedText, Z_INDEX } from 'theme'
import { CloseIcon, ExternalLink, ThemedText } from 'theme'
import { Z_INDEX } from 'theme/zIndex'
import { useUnsupportedTokens } from '../../hooks/Tokens'
import { ExplorerDataType, getExplorerLink } from '../../utils/getExplorerLink'

View File

@@ -4,7 +4,7 @@ import { ReactNode } from 'react'
import { AlertTriangle } from 'react-feather'
import { Text } from 'rebass'
import styled, { css } from 'styled-components/macro'
import { Z_INDEX } from 'theme'
import { Z_INDEX } from 'theme/zIndex'
import { AutoColumn } from '../Column'

View File

@@ -9,6 +9,7 @@ import { SupportedChainId } from 'constants/chains'
import UNISWAP_LOGO_URL from '../assets/svg/logo.svg'
import { RPC_URLS } from '../constants/networks'
import { RPC_PROVIDERS } from '../constants/providers'
export enum ConnectionType {
INJECTED = 'INJECTED',
@@ -29,7 +30,7 @@ function onError(error: Error) {
}
const [web3Network, web3NetworkHooks] = initializeConnector<Network>(
(actions) => new Network({ actions, urlMap: RPC_URLS, defaultChainId: 1 })
(actions) => new Network({ actions, urlMap: RPC_PROVIDERS, defaultChainId: 1 })
)
export const networkConnection: Connection = {
connector: web3Network,
@@ -73,7 +74,7 @@ const [web3CoinbaseWallet, web3CoinbaseWalletHooks] = initializeConnector<Coinba
new CoinbaseWallet({
actions,
options: {
url: RPC_URLS[SupportedChainId.MAINNET],
url: RPC_URLS[SupportedChainId.MAINNET][0],
appName: 'Uniswap',
appLogoUrl: UNISWAP_LOGO_URL,
reloadOnDisconnect: false,

View File

@@ -1,5 +1,3 @@
import { JsonRpcProvider } from '@ethersproject/providers'
import { SupportedChainId } from './chains'
const INFURA_KEY = process.env.REACT_APP_INFURA_KEY
@@ -7,23 +5,133 @@ if (typeof INFURA_KEY === 'undefined') {
throw new Error(`REACT_APP_INFURA_KEY must be a defined environment variable`)
}
export const MAINNET_PROVIDER = new JsonRpcProvider(`https://mainnet.infura.io/v3/${INFURA_KEY}`)
/**
* Fallback JSON-RPC endpoints.
* These are used if the integrator does not provide an endpoint, or if the endpoint does not work.
*
* MetaMask allows switching to any URL, but displays a warning if it is not on the "Safe" list:
* https://github.com/MetaMask/metamask-mobile/blob/bdb7f37c90e4fc923881a07fca38d4e77c73a579/app/core/RPCMethods/wallet_addEthereumChain.js#L228-L235
* https://chainid.network/chains.json
*
* These "Safe" URLs are listed first, followed by other fallback URLs, which are taken from chainlist.org.
*/
export const FALLBACK_URLS: { [key in SupportedChainId]: string[] } = {
[SupportedChainId.MAINNET]: [
// "Safe" URLs
'https://api.mycryptoapi.com/eth',
'https://cloudflare-eth.com',
// "Fallback" URLs
'https://rpc.ankr.com/eth',
'https://eth-mainnet.public.blastapi.io',
],
[SupportedChainId.ROPSTEN]: [
// "Fallback" URLs
'https://rpc.ankr.com/eth_ropsten',
],
[SupportedChainId.RINKEBY]: [
// "Fallback" URLs
'https://rinkeby-light.eth.linkpool.io/',
],
[SupportedChainId.GOERLI]: [
// "Safe" URLs
'https://rpc.goerli.mudit.blog/',
// "Fallback" URLs
'https://rpc.ankr.com/eth_goerli',
],
[SupportedChainId.KOVAN]: [
// "Safe" URLs
'https://kovan.poa.network',
// "Fallback" URLs
'https://eth-kovan.public.blastapi.io',
],
[SupportedChainId.POLYGON]: [
// "Safe" URLs
'https://polygon-rpc.com/',
'https://rpc-mainnet.matic.network',
'https://matic-mainnet.chainstacklabs.com',
'https://rpc-mainnet.maticvigil.com',
'https://rpc-mainnet.matic.quiknode.pro',
'https://matic-mainnet-full-rpc.bwarelabs.com',
],
[SupportedChainId.POLYGON_MUMBAI]: [
// "Safe" URLs
'https://matic-mumbai.chainstacklabs.com',
'https://rpc-mumbai.maticvigil.com',
'https://matic-testnet-archive-rpc.bwarelabs.com',
],
[SupportedChainId.ARBITRUM_ONE]: [
// "Safe" URLs
'https://arb1.arbitrum.io/rpc',
// "Fallback" URLs
'https://arbitrum.public-rpc.com',
],
[SupportedChainId.ARBITRUM_RINKEBY]: [
// "Safe" URLs
'https://rinkeby.arbitrum.io/rpc',
],
[SupportedChainId.OPTIMISM]: [
// "Safe" URLs
'https://mainnet.optimism.io/',
// "Fallback" URLs
'https://rpc.ankr.com/optimism',
],
[SupportedChainId.OPTIMISTIC_KOVAN]: [
// "Safe" URLs
'https://kovan.optimism.io',
],
[SupportedChainId.CELO]: [
// "Safe" URLs
`https://forno.celo.org`,
],
[SupportedChainId.CELO_ALFAJORES]: [
// "Safe" URLs
`https://alfajores-forno.celo-testnet.org`,
],
}
/**
* These are the network URLs used by the interface when there is not another available source of chain data
* Known JSON-RPC endpoints.
* These are the URLs used by the interface when there is not another available source of chain data.
*/
export const RPC_URLS: { [key in SupportedChainId]: string } = {
[SupportedChainId.MAINNET]: `https://mainnet.infura.io/v3/${INFURA_KEY}`,
[SupportedChainId.RINKEBY]: `https://rinkeby.infura.io/v3/${INFURA_KEY}`,
[SupportedChainId.ROPSTEN]: `https://ropsten.infura.io/v3/${INFURA_KEY}`,
[SupportedChainId.GOERLI]: `https://goerli.infura.io/v3/${INFURA_KEY}`,
[SupportedChainId.KOVAN]: `https://kovan.infura.io/v3/${INFURA_KEY}`,
[SupportedChainId.OPTIMISM]: `https://optimism-mainnet.infura.io/v3/${INFURA_KEY}`,
[SupportedChainId.OPTIMISTIC_KOVAN]: `https://optimism-kovan.infura.io/v3/${INFURA_KEY}`,
[SupportedChainId.ARBITRUM_ONE]: `https://arbitrum-mainnet.infura.io/v3/${INFURA_KEY}`,
[SupportedChainId.ARBITRUM_RINKEBY]: `https://arbitrum-rinkeby.infura.io/v3/${INFURA_KEY}`,
[SupportedChainId.POLYGON]: `https://polygon-mainnet.infura.io/v3/${INFURA_KEY}`,
[SupportedChainId.POLYGON_MUMBAI]: `https://polygon-mumbai.infura.io/v3/${INFURA_KEY}`,
[SupportedChainId.CELO]: `https://forno.celo.org`,
[SupportedChainId.CELO_ALFAJORES]: `https://alfajores-forno.celo-testnet.org`,
export const RPC_URLS: { [key in SupportedChainId]: string[] } = {
[SupportedChainId.MAINNET]: [
`https://mainnet.infura.io/v3/${INFURA_KEY}`,
...FALLBACK_URLS[SupportedChainId.MAINNET],
],
[SupportedChainId.RINKEBY]: [
`https://rinkeby.infura.io/v3/${INFURA_KEY}`,
...FALLBACK_URLS[SupportedChainId.RINKEBY],
],
[SupportedChainId.ROPSTEN]: [
`https://ropsten.infura.io/v3/${INFURA_KEY}`,
...FALLBACK_URLS[SupportedChainId.ROPSTEN],
],
[SupportedChainId.GOERLI]: [`https://goerli.infura.io/v3/${INFURA_KEY}`, ...FALLBACK_URLS[SupportedChainId.GOERLI]],
[SupportedChainId.KOVAN]: [`https://kovan.infura.io/v3/${INFURA_KEY}`, ...FALLBACK_URLS[SupportedChainId.KOVAN]],
[SupportedChainId.OPTIMISM]: [
`https://optimism-mainnet.infura.io/v3/${INFURA_KEY}`,
...FALLBACK_URLS[SupportedChainId.OPTIMISM],
],
[SupportedChainId.OPTIMISTIC_KOVAN]: [
`https://optimism-kovan.infura.io/v3/${INFURA_KEY}`,
...FALLBACK_URLS[SupportedChainId.OPTIMISTIC_KOVAN],
],
[SupportedChainId.ARBITRUM_ONE]: [
`https://arbitrum-mainnet.infura.io/v3/${INFURA_KEY}`,
...FALLBACK_URLS[SupportedChainId.ARBITRUM_ONE],
],
[SupportedChainId.ARBITRUM_RINKEBY]: [
`https://arbitrum-rinkeby.infura.io/v3/${INFURA_KEY}`,
...FALLBACK_URLS[SupportedChainId.ARBITRUM_RINKEBY],
],
[SupportedChainId.POLYGON]: [
`https://polygon-mainnet.infura.io/v3/${INFURA_KEY}`,
...FALLBACK_URLS[SupportedChainId.POLYGON],
],
[SupportedChainId.POLYGON_MUMBAI]: [
`https://polygon-mumbai.infura.io/v3/${INFURA_KEY}`,
...FALLBACK_URLS[SupportedChainId.POLYGON_MUMBAI],
],
[SupportedChainId.CELO]: FALLBACK_URLS[SupportedChainId.CELO],
[SupportedChainId.CELO_ALFAJORES]: FALLBACK_URLS[SupportedChainId.CELO_ALFAJORES],
}

View File

@@ -0,0 +1,66 @@
import { deepCopy } from '@ethersproject/properties'
// This is the only file which should instantiate new Providers.
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { StaticJsonRpcProvider } from '@ethersproject/providers'
import { isPlain } from '@reduxjs/toolkit'
import { SupportedChainId } from './chains'
import { RPC_URLS } from './networks'
class AppJsonRpcProvider extends StaticJsonRpcProvider {
private _blockCache = new Map<string, Promise<any>>()
get blockCache() {
// If the blockCache has not yet been initialized this block, do so by
// setting a listener to clear it on the next block.
if (!this._blockCache.size) {
this.once('block', () => this._blockCache.clear())
}
return this._blockCache
}
constructor(urls: string[]) {
super(urls[0])
}
send(method: string, params: Array<any>): Promise<any> {
// Only cache eth_call's.
if (method !== 'eth_call') return super.send(method, params)
// Only cache if params are serializable.
if (!isPlain(params)) return super.send(method, params)
const key = `call:${JSON.stringify(params)}`
const cached = this.blockCache.get(key)
if (cached) {
this.emit('debug', {
action: 'request',
request: deepCopy({ method, params, id: 'cache' }),
provider: this,
})
return cached
}
const result = super.send(method, params)
this.blockCache.set(key, result)
return result
}
}
/**
* These are the only JsonRpcProviders used directly by the interface.
*/
export const RPC_PROVIDERS: { [key in SupportedChainId]: StaticJsonRpcProvider } = {
[SupportedChainId.MAINNET]: new AppJsonRpcProvider(RPC_URLS[SupportedChainId.MAINNET]),
[SupportedChainId.RINKEBY]: new AppJsonRpcProvider(RPC_URLS[SupportedChainId.RINKEBY]),
[SupportedChainId.ROPSTEN]: new AppJsonRpcProvider(RPC_URLS[SupportedChainId.ROPSTEN]),
[SupportedChainId.GOERLI]: new AppJsonRpcProvider(RPC_URLS[SupportedChainId.GOERLI]),
[SupportedChainId.KOVAN]: new AppJsonRpcProvider(RPC_URLS[SupportedChainId.KOVAN]),
[SupportedChainId.OPTIMISM]: new AppJsonRpcProvider(RPC_URLS[SupportedChainId.OPTIMISM]),
[SupportedChainId.OPTIMISTIC_KOVAN]: new AppJsonRpcProvider(RPC_URLS[SupportedChainId.OPTIMISTIC_KOVAN]),
[SupportedChainId.ARBITRUM_ONE]: new AppJsonRpcProvider(RPC_URLS[SupportedChainId.ARBITRUM_ONE]),
[SupportedChainId.ARBITRUM_RINKEBY]: new AppJsonRpcProvider(RPC_URLS[SupportedChainId.ARBITRUM_RINKEBY]),
[SupportedChainId.POLYGON]: new AppJsonRpcProvider(RPC_URLS[SupportedChainId.POLYGON]),
[SupportedChainId.POLYGON_MUMBAI]: new AppJsonRpcProvider(RPC_URLS[SupportedChainId.POLYGON_MUMBAI]),
[SupportedChainId.CELO]: new AppJsonRpcProvider(RPC_URLS[SupportedChainId.CELO]),
[SupportedChainId.CELO_ALFAJORES]: new AppJsonRpcProvider(RPC_URLS[SupportedChainId.CELO_ALFAJORES]),
}

View File

@@ -2,8 +2,7 @@ import { Plural, Trans } from '@lingui/macro'
import WarningCache, { TOKEN_LIST_TYPES } from './TokenSafetyLookupTable'
// TODO: Replace this with Steph's article when it is available.
export const TOKEN_SAFETY_ARTICLE = 'https://help.uniswap.org/en/'
export const TOKEN_SAFETY_ARTICLE = 'https://support.uniswap.org/hc/en-us/articles/8723118437133'
export enum WARNING_LEVEL {
MEDIUM,

View File

@@ -0,0 +1,9 @@
export enum FeatureFlag {
navBar = 'navBar',
nft = 'nfts',
redesign = 'redesign',
tokens = 'tokens',
tokensNetworkFilter = 'tokensNetworkFilter',
tokenSafety = 'tokenSafety',
traceJsonRpc = 'traceJsonRpc',
}

View File

@@ -0,0 +1,7 @@
import { BaseVariant, FeatureFlag, useBaseFlag } from '../index'
export function useTraceJsonRpcFlag(): BaseVariant {
return useBaseFlag(FeatureFlag.traceJsonRpc)
}
export { BaseVariant as TraceJsonRpcVariant }

View File

@@ -1,6 +1,6 @@
import { useAtom } from 'jotai'
import { atomWithStorage, useAtomValue } from 'jotai/utils'
import { atomWithStorage, useAtomValue, useUpdateAtom } from 'jotai/utils'
import { createContext, ReactNode, useCallback, useContext } from 'react'
export { FeatureFlag } from './flags/featureFlags'
interface FeatureFlagsContextType {
isLoaded: boolean
@@ -22,14 +22,16 @@ export function useFeatureFlagsContext(): FeatureFlagsContextType {
export const featureFlagSettings = atomWithStorage<Record<string, string>>('featureFlags', {})
export function useUpdateFlag() {
const [featureFlags, setFeatureFlags] = useAtom(featureFlagSettings)
const setFeatureFlags = useUpdateAtom(featureFlagSettings)
return useCallback(
(featureFlag: string, option: string) => {
featureFlags[featureFlag] = option
setFeatureFlags(featureFlags)
setFeatureFlags((featureFlags) => ({
...featureFlags,
[featureFlag]: option,
}))
},
[featureFlags, setFeatureFlags]
[setFeatureFlags]
)
}
@@ -53,16 +55,6 @@ export enum BaseVariant {
Enabled = 'enabled',
}
export enum FeatureFlag {
navBar = 'navBar',
wallet = 'wallet',
nft = 'nfts',
redesign = 'redesign',
tokens = 'tokens',
tokensNetworkFilter = 'tokensNetworkFilter',
tokenSafety = 'tokenSafety',
}
export function useBaseFlag(flag: string): BaseVariant {
switch (useFeatureFlagsContext().flags[flag]) {
case 'enabled':

View File

@@ -1,9 +1,41 @@
import { Environment, Network, RecordSource, Store } from 'relay-runtime'
import ms from 'ms.macro'
import { Variables } from 'react-relay'
import { Environment, Network, RecordSource, RequestParameters, Store } from 'relay-runtime'
import RelayQueryResponseCache from 'relay-runtime/lib/network/RelayQueryResponseCache'
import fetchGraphQL from './fetchGraphQL'
// max number of request in cache, least-recently updated entries purged first
const size = 250
// number in milliseconds, how long records stay valid in cache
const ttl = ms`5m`
export const cache = new RelayQueryResponseCache({ size, ttl })
const fetchQuery = async function wrappedFetchQuery(params: RequestParameters, variables: Variables) {
const queryID = params.name
const cachedData = cache.get(queryID, variables)
if (cachedData !== null) return cachedData
return fetchGraphQL(params, variables).then((data) => {
if (params.operationKind !== 'mutation') {
cache.set(queryID, variables, data)
}
return data
})
}
// This property tells Relay to not immediately clear its cache when the user
// navigates around the app. Relay will hold onto the specified number of
// query results, allowing the user to return to recently visited pages
// and reusing cached data if its available/fresh.
const gcReleaseBufferSize = 10
const store = new Store(new RecordSource(), { gcReleaseBufferSize })
const network = Network.create(fetchQuery)
// Export a singleton instance of Relay Environment configured with our network function:
export default new Environment({
network: Network.create(fetchGraphQL),
store: new Store(new RecordSource()),
network,
store,
})

352
src/graphql/data/Token.ts Normal file
View File

@@ -0,0 +1,352 @@
import graphql from 'babel-plugin-relay/macro'
import { useEffect, useState } from 'react'
import { fetchQuery, useFragment, useLazyLoadQuery, useRelayEnvironment } from 'react-relay'
import { TokenPriceQuery } from './__generated__/TokenPriceQuery.graphql'
import { TokenPrices$data, TokenPrices$key } from './__generated__/TokenPrices.graphql'
import { Chain, HistoryDuration, TokenQuery, TokenQuery$data } from './__generated__/TokenQuery.graphql'
import { TokenTopQuery, TokenTopQuery$data } from './__generated__/TokenTopQuery.graphql'
export enum TimePeriod {
HOUR,
DAY,
WEEK,
MONTH,
YEAR,
ALL,
}
function toHistoryDuration(timePeriod: TimePeriod): HistoryDuration {
switch (timePeriod) {
case TimePeriod.HOUR:
return 'HOUR'
case TimePeriod.DAY:
return 'DAY'
case TimePeriod.WEEK:
return 'WEEK'
case TimePeriod.MONTH:
return 'MONTH'
case TimePeriod.YEAR:
return 'YEAR'
case TimePeriod.ALL:
return 'MAX'
}
}
export type PricePoint = { value: number; timestamp: number }
const topTokensQuery = graphql`
query TokenTopQuery($page: Int!, $duration: HistoryDuration!) {
topTokenProjects(orderBy: MARKET_CAP, pageSize: 20, currency: USD, page: $page) {
description
homepageUrl
twitterName
name
tokens {
chain
address
symbol
}
prices: markets(currencies: [USD]) {
...TokenPrices
}
markets(currencies: [USD]) {
price {
value
currency
}
marketCap {
value
currency
}
fullyDilutedMarketCap {
value
currency
}
volume1D: volume(duration: DAY) {
value
currency
}
volume1W: volume(duration: WEEK) {
value
currency
}
volume1M: volume(duration: MONTH) {
value
currency
}
volume1Y: volume(duration: YEAR) {
value
currency
}
pricePercentChange24h {
currency
value
}
pricePercentChange1W: pricePercentChange(duration: WEEK) {
currency
value
}
pricePercentChange1M: pricePercentChange(duration: MONTH) {
currency
value
}
pricePercentChange1Y: pricePercentChange(duration: YEAR) {
currency
value
}
priceHigh52W: priceHighLow(duration: YEAR, highLow: HIGH) {
value
currency
}
priceLow52W: priceHighLow(duration: YEAR, highLow: LOW) {
value
currency
}
}
}
}
`
const tokenPricesFragment = graphql`
fragment TokenPrices on TokenProjectMarket {
priceHistory(duration: $duration) {
timestamp
value
}
}
`
type CachedTopToken = NonNullable<NonNullable<TokenTopQuery$data>['topTokenProjects']>[number]
let cachedTopTokens: Record<string, CachedTopToken> = {}
export function useTopTokenQuery(page: number, timePeriod: TimePeriod) {
const topTokens = useLazyLoadQuery<TokenTopQuery>(topTokensQuery, { page, duration: toHistoryDuration(timePeriod) })
cachedTopTokens =
topTokens.topTokenProjects?.reduce((acc, current) => {
const address = current?.tokens?.[0].address
if (address) acc[address] = current
return acc
}, {} as Record<string, CachedTopToken>) ?? {}
console.log(cachedTopTokens)
return topTokens
}
const tokenQuery = graphql`
query TokenQuery($contract: ContractInput!, $duration: HistoryDuration!, $skip: Boolean = false) {
tokenProjects(contracts: [$contract]) @skip(if: $skip) {
description
homepageUrl
twitterName
name
tokens {
chain
address
symbol
}
prices: markets(currencies: [USD]) {
...TokenPrices
}
markets(currencies: [USD]) {
price {
value
currency
}
marketCap {
value
currency
}
fullyDilutedMarketCap {
value
currency
}
volume1D: volume(duration: DAY) {
value
currency
}
volume1W: volume(duration: WEEK) {
value
currency
}
volume1M: volume(duration: MONTH) {
value
currency
}
volume1Y: volume(duration: YEAR) {
value
currency
}
pricePercentChange24h {
currency
value
}
pricePercentChange1W: pricePercentChange(duration: WEEK) {
currency
value
}
pricePercentChange1M: pricePercentChange(duration: MONTH) {
currency
value
}
pricePercentChange1Y: pricePercentChange(duration: YEAR) {
currency
value
}
priceHigh52W: priceHighLow(duration: YEAR, highLow: HIGH) {
value
currency
}
priceLow52W: priceHighLow(duration: YEAR, highLow: LOW) {
value
currency
}
}
}
}
`
export function useTokenQuery(address: string, chain: Chain, timePeriod: TimePeriod) {
const cachedTopToken = cachedTopTokens[address]
const data = useLazyLoadQuery<TokenQuery>(tokenQuery, {
contract: { address, chain },
duration: toHistoryDuration(timePeriod),
skip: !!cachedTopToken,
})
return !cachedTopToken ? data : { tokenProjects: [{ ...cachedTopToken }] }
}
const tokenPriceQuery = graphql`
query TokenPriceQuery(
$contract: ContractInput!
$skip1H: Boolean!
$skip1D: Boolean!
$skip1W: Boolean!
$skip1M: Boolean!
$skip1Y: Boolean!
$skipMax: Boolean!
) {
tokenProjects(contracts: [$contract]) {
markets(currencies: [USD]) {
priceHistory1H: priceHistory(duration: HOUR) @skip(if: $skip1H) {
timestamp
value
}
priceHistory1D: priceHistory(duration: DAY) @skip(if: $skip1D) {
timestamp
value
}
priceHistory1W: priceHistory(duration: WEEK) @skip(if: $skip1W) {
timestamp
value
}
priceHistory1M: priceHistory(duration: MONTH) @skip(if: $skip1M) {
timestamp
value
}
priceHistory1Y: priceHistory(duration: YEAR) @skip(if: $skip1Y) {
timestamp
value
}
priceHistoryMAX: priceHistory(duration: MAX) @skip(if: $skipMax) {
timestamp
value
}
}
}
}
`
export function filterPrices(prices: TokenPrices$data['priceHistory'] | undefined) {
return prices?.filter((p): p is PricePoint => Boolean(p && p.value))
}
export function useTokenPricesFromFragment(key: TokenPrices$key | null | undefined) {
const fetchedTokenPrices = useFragment(tokenPricesFragment, key ?? null)?.priceHistory
return filterPrices(fetchedTokenPrices)
}
export function useTokenPricesCached(
key: TokenPrices$key | null | undefined,
address: string,
chain: Chain,
timePeriod: TimePeriod
) {
// Attempt to use token prices already provided by TokenDetails / TopToken queries
const environment = useRelayEnvironment()
const fetchedTokenPrices = useFragment(tokenPricesFragment, key ?? null)?.priceHistory
const [priceMap, setPriceMap] = useState(
new Map<TimePeriod, PricePoint[] | undefined>([[timePeriod, filterPrices(fetchedTokenPrices)]])
)
function updatePrices(key: TimePeriod, data?: PricePoint[]) {
setPriceMap(new Map(priceMap.set(key, data)))
}
// Fetch the other timePeriods after first render
useEffect(() => {
// Fetch all time periods except the one already populated
fetchQuery<TokenPriceQuery>(environment, tokenPriceQuery, {
contract: { address, chain },
skip1H: timePeriod === TimePeriod.HOUR && !!fetchedTokenPrices,
skip1D: timePeriod === TimePeriod.DAY && !!fetchedTokenPrices,
skip1W: timePeriod === TimePeriod.WEEK && !!fetchedTokenPrices,
skip1M: timePeriod === TimePeriod.MONTH && !!fetchedTokenPrices,
skip1Y: timePeriod === TimePeriod.YEAR && !!fetchedTokenPrices,
skipMax: timePeriod === TimePeriod.ALL && !!fetchedTokenPrices,
}).subscribe({
next: (data) => {
const markets = data.tokenProjects?.[0]?.markets?.[0]
if (markets) {
markets.priceHistory1H && updatePrices(TimePeriod.HOUR, filterPrices(markets.priceHistory1H))
markets.priceHistory1D && updatePrices(TimePeriod.DAY, filterPrices(markets.priceHistory1D))
markets.priceHistory1W && updatePrices(TimePeriod.WEEK, filterPrices(markets.priceHistory1W))
markets.priceHistory1M && updatePrices(TimePeriod.MONTH, filterPrices(markets.priceHistory1M))
markets.priceHistory1Y && updatePrices(TimePeriod.YEAR, filterPrices(markets.priceHistory1Y))
markets.priceHistoryMAX && updatePrices(TimePeriod.ALL, filterPrices(markets.priceHistoryMAX))
}
},
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return { priceMap }
}
export type SingleTokenData = NonNullable<TokenQuery$data['tokenProjects']>[number]
export function getDurationDetails(data: SingleTokenData, timePeriod: TimePeriod) {
let volume = null
let pricePercentChange = null
const markets = data?.markets?.[0]
if (markets) {
switch (timePeriod) {
case TimePeriod.HOUR:
pricePercentChange = null
break
case TimePeriod.DAY:
volume = markets.volume1D?.value
pricePercentChange = markets.pricePercentChange24h?.value
break
case TimePeriod.WEEK:
volume = markets.volume1W?.value
pricePercentChange = markets.pricePercentChange1W?.value
break
case TimePeriod.MONTH:
volume = markets.volume1M?.value
pricePercentChange = markets.pricePercentChange1M?.value
break
case TimePeriod.YEAR:
volume = markets.volume1Y?.value
pricePercentChange = markets.pricePercentChange1Y?.value
break
case TimePeriod.ALL:
volume = null
pricePercentChange = null
break
}
}
return { volume, pricePercentChange }
}

View File

@@ -1,89 +0,0 @@
import graphql from 'babel-plugin-relay/macro'
import { SupportedChainId } from 'constants/chains'
import { useLazyLoadQuery } from 'react-relay'
import type { Chain, TokenDetailQuery as TokenDetailQueryType } from './__generated__/TokenDetailQuery.graphql'
export function chainIdToChainName(networkId: SupportedChainId): Chain {
switch (networkId) {
case SupportedChainId.MAINNET:
return 'ETHEREUM'
case SupportedChainId.ARBITRUM_ONE:
return 'ARBITRUM'
case SupportedChainId.OPTIMISM:
return 'OPTIMISM'
case SupportedChainId.POLYGON:
return 'POLYGON'
default:
return 'ETHEREUM'
}
}
export function useTokenDetailQuery(address: string, chain: Chain) {
const tokenDetail = useLazyLoadQuery<TokenDetailQueryType>(
graphql`
query TokenDetailQuery($contract: ContractInput!) {
tokenProjects(contracts: [$contract]) {
description
homepageUrl
twitterName
name
markets(currencies: [USD]) {
price {
value
currency
}
marketCap {
value
currency
}
fullyDilutedMarketCap {
value
currency
}
volume24h: volume(duration: DAY) {
value
currency
}
priceHigh52W: priceHighLow(duration: YEAR, highLow: HIGH) {
value
currency
}
priceLow52W: priceHighLow(duration: YEAR, highLow: LOW) {
value
currency
}
}
tokens {
chain
address
symbol
decimals
}
}
}
`,
{
contract: {
address,
chain,
},
}
)
const { description, homepageUrl, twitterName, name, markets, tokens } = tokenDetail?.tokenProjects?.[0] ?? {}
const { price, marketCap, fullyDilutedMarketCap, volume24h, priceHigh52W, priceLow52W } = markets?.[0] ?? {}
return {
description,
homepageUrl,
twitterName,
name,
markets,
tokens,
price,
marketCap,
fullyDilutedMarketCap,
volume24h,
priceHigh52W,
priceLow52W,
}
}

View File

@@ -1,69 +0,0 @@
import graphql from 'babel-plugin-relay/macro'
import { useLazyLoadQuery } from 'react-relay'
import type { Chain, TokenPriceQuery as TokenPriceQueryType } from './__generated__/TokenPriceQuery.graphql'
import { TimePeriod } from './TopTokenQuery'
export function useTokenPriceQuery(address: string, timePeriod: TimePeriod, chain: Chain) {
const tokenPrices = useLazyLoadQuery<TokenPriceQueryType>(
graphql`
query TokenPriceQuery($contract: ContractInput!) {
tokenProjects(contracts: [$contract]) {
name
markets(currencies: [USD]) {
priceHistory1H: priceHistory(duration: HOUR) {
timestamp
value
}
priceHistory1D: priceHistory(duration: DAY) {
timestamp
value
}
priceHistory1W: priceHistory(duration: WEEK) {
timestamp
value
}
priceHistory1M: priceHistory(duration: MONTH) {
timestamp
value
}
priceHistory1Y: priceHistory(duration: YEAR) {
timestamp
value
}
}
tokens {
chain
address
symbol
decimals
}
}
}
`,
{
contract: {
address,
chain,
},
}
)
const { priceHistory1H, priceHistory1D, priceHistory1W, priceHistory1M, priceHistory1Y } =
tokenPrices.tokenProjects?.[0]?.markets?.[0] ?? {}
switch (timePeriod) {
case TimePeriod.HOUR:
return priceHistory1H ?? []
case TimePeriod.DAY:
return priceHistory1D ?? []
case TimePeriod.WEEK:
return priceHistory1W ?? []
case TimePeriod.MONTH:
return priceHistory1M ?? []
case TimePeriod.YEAR:
return priceHistory1Y ?? []
case TimePeriod.ALL:
//TODO: Add functionality for ALL, without requesting it at same time as rest of data for performance reasons
return priceHistory1Y ?? []
}
}

View File

@@ -1,147 +0,0 @@
import graphql from 'babel-plugin-relay/macro'
import { useLazyLoadQuery } from 'react-relay'
import type { Chain, Currency, TopTokenQuery as TopTokenQueryType } from './__generated__/TopTokenQuery.graphql'
export enum TimePeriod {
HOUR,
DAY,
WEEK,
MONTH,
YEAR,
ALL,
}
interface IAmount {
currency: Currency | null
value: number | null
}
export type TokenData = {
name: string | null
address: string
chain: Chain | null
symbol: string | null
price: IAmount | null | undefined
marketCap: IAmount | null | undefined
volume: Record<TimePeriod, IAmount | null | undefined>
percentChange: Record<TimePeriod, IAmount | null | undefined>
}
export interface UseTopTokensResult {
data: TokenData[] | undefined
error: string | null
loading: boolean
}
export function useTopTokenQuery(page: number) {
const topTokenData = useLazyLoadQuery<TopTokenQueryType>(
graphql`
query TopTokenQuery($page: Int!) {
topTokenProjects(orderBy: MARKET_CAP, pageSize: 100, currency: USD, page: $page) {
name
tokens {
chain
address
symbol
}
markets(currencies: [USD]) {
price {
value
currency
}
marketCap {
value
currency
}
fullyDilutedMarketCap {
value
currency
}
volume1H: volume(duration: HOUR) {
value
currency
}
volume1D: volume(duration: DAY) {
value
currency
}
volume1W: volume(duration: WEEK) {
value
currency
}
volume1M: volume(duration: MONTH) {
value
currency
}
volume1Y: volume(duration: YEAR) {
value
currency
}
volumeAll: volume(duration: MAX) {
value
currency
}
pricePercentChange1H: pricePercentChange(duration: HOUR) {
currency
value
}
pricePercentChange24h {
currency
value
}
pricePercentChange1W: pricePercentChange(duration: WEEK) {
currency
value
}
pricePercentChange1M: pricePercentChange(duration: MONTH) {
currency
value
}
pricePercentChange1Y: pricePercentChange(duration: YEAR) {
currency
value
}
pricePercentChangeAll: pricePercentChange(duration: MAX) {
currency
value
}
}
}
}
`,
{
page,
}
)
const topTokens: TokenData[] | undefined = topTokenData.topTokenProjects?.map((token) =>
token?.tokens?.[0].address
? {
name: token?.name,
address: token?.tokens?.[0].address,
chain: token?.tokens?.[0].chain,
symbol: token?.tokens?.[0].symbol,
price: token?.markets?.[0]?.price,
marketCap: token?.markets?.[0]?.marketCap,
volume: {
[TimePeriod.HOUR]: token?.markets?.[0]?.volume1H,
[TimePeriod.DAY]: token?.markets?.[0]?.volume1D,
[TimePeriod.WEEK]: token?.markets?.[0]?.volume1W,
[TimePeriod.MONTH]: token?.markets?.[0]?.volume1M,
[TimePeriod.YEAR]: token?.markets?.[0]?.volume1Y,
[TimePeriod.ALL]: token?.markets?.[0]?.volumeAll,
},
percentChange: {
[TimePeriod.HOUR]: token?.markets?.[0]?.pricePercentChange1H,
[TimePeriod.DAY]: token?.markets?.[0]?.pricePercentChange24h,
[TimePeriod.WEEK]: token?.markets?.[0]?.pricePercentChange1W,
[TimePeriod.MONTH]: token?.markets?.[0]?.pricePercentChange1M,
[TimePeriod.YEAR]: token?.markets?.[0]?.pricePercentChange1Y,
[TimePeriod.ALL]: token?.markets?.[0]?.pricePercentChangeAll,
},
}
: ({} as TokenData)
)
return topTokens
}

View File

@@ -13,7 +13,7 @@ import { InterfaceTrade } from 'state/routing/types'
import useGasPrice from './useGasPrice'
import useStablecoinPrice, { useStablecoinValue } from './useStablecoinPrice'
const V3_SWAP_DEFAULT_SLIPPAGE = new Percent(50, 10_000) // .50%
export const V3_SWAP_DEFAULT_SLIPPAGE = new Percent(50, 10_000) // .50%
const ONE_TENTHS_PERCENT = new Percent(10, 10_000) // .10%
export const DEFAULT_AUTO_SLIPPAGE = ONE_TENTHS_PERCENT
const GAS_ESTIMATE_BUFFER = new Percent(10, 100) // 10%

View File

@@ -1,7 +1,9 @@
import { renderHook } from '@testing-library/react'
import { CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { DAI, USDC_MAINNET } from 'constants/tokens'
import { RouterPreference } from 'state/routing/slice'
import { TradeState } from 'state/routing/types'
import { useClientSideRouter } from 'state/user/hooks'
import { useRoutingAPITrade } from '../state/routing/useRoutingAPITrade'
import useAutoRouterSupported from './useAutoRouterSupported'
@@ -25,6 +27,7 @@ const mockUseAutoRouterSupported = useAutoRouterSupported as jest.MockedFunction
const mockUseIsWindowVisible = useIsWindowVisible as jest.MockedFunction<typeof useIsWindowVisible>
const mockUseRoutingAPITrade = useRoutingAPITrade as jest.MockedFunction<typeof useRoutingAPITrade>
const mockUseClientSideRouter = useClientSideRouter as jest.MockedFunction<typeof useClientSideRouter>
const mockUseClientSideV3Trade = useClientSideV3Trade as jest.MockedFunction<typeof useClientSideV3Trade>
// helpers to set mock expectations
@@ -42,6 +45,7 @@ beforeEach(() => {
mockUseIsWindowVisible.mockReturnValue(true)
mockUseAutoRouterSupported.mockReturnValue(true)
mockUseClientSideRouter.mockReturnValue([true, () => undefined])
})
describe('#useBestV3Trade ExactIn', () => {
@@ -52,7 +56,7 @@ describe('#useBestV3Trade ExactIn', () => {
const { result } = renderHook(() => useBestTrade(TradeType.EXACT_INPUT, USDCAmount, DAI))
expect(mockUseRoutingAPITrade).toHaveBeenCalledWith(TradeType.EXACT_INPUT, undefined, DAI)
expect(mockUseRoutingAPITrade).toHaveBeenCalledWith(TradeType.EXACT_INPUT, undefined, DAI, RouterPreference.CLIENT)
expect(mockUseClientSideV3Trade).toHaveBeenCalledWith(TradeType.EXACT_INPUT, USDCAmount, DAI)
expect(result.current).toEqual({ state: TradeState.VALID, trade: undefined })
})
@@ -64,7 +68,7 @@ describe('#useBestV3Trade ExactIn', () => {
const { result } = renderHook(() => useBestTrade(TradeType.EXACT_INPUT, USDCAmount, DAI))
expect(mockUseRoutingAPITrade).toHaveBeenCalledWith(TradeType.EXACT_INPUT, undefined, DAI)
expect(mockUseRoutingAPITrade).toHaveBeenCalledWith(TradeType.EXACT_INPUT, undefined, DAI, RouterPreference.CLIENT)
expect(mockUseClientSideV3Trade).toHaveBeenCalledWith(TradeType.EXACT_INPUT, USDCAmount, DAI)
expect(result.current).toEqual({ state: TradeState.VALID, trade: undefined })
})
@@ -128,7 +132,12 @@ describe('#useBestV3Trade ExactOut', () => {
const { result } = renderHook(() => useBestTrade(TradeType.EXACT_OUTPUT, DAIAmount, USDC_MAINNET))
expect(mockUseRoutingAPITrade).toHaveBeenCalledWith(TradeType.EXACT_OUTPUT, undefined, USDC_MAINNET)
expect(mockUseRoutingAPITrade).toHaveBeenCalledWith(
TradeType.EXACT_OUTPUT,
undefined,
USDC_MAINNET,
RouterPreference.CLIENT
)
expect(mockUseClientSideV3Trade).toHaveBeenCalledWith(TradeType.EXACT_OUTPUT, DAIAmount, USDC_MAINNET)
expect(result.current).toEqual({ state: TradeState.VALID, trade: undefined })
})
@@ -140,7 +149,12 @@ describe('#useBestV3Trade ExactOut', () => {
const { result } = renderHook(() => useBestTrade(TradeType.EXACT_OUTPUT, DAIAmount, USDC_MAINNET))
expect(mockUseRoutingAPITrade).toHaveBeenCalledWith(TradeType.EXACT_OUTPUT, undefined, USDC_MAINNET)
expect(mockUseRoutingAPITrade).toHaveBeenCalledWith(
TradeType.EXACT_OUTPUT,
undefined,
USDC_MAINNET,
RouterPreference.CLIENT
)
expect(mockUseClientSideV3Trade).toHaveBeenCalledWith(TradeType.EXACT_OUTPUT, DAIAmount, USDC_MAINNET)
expect(result.current).toEqual({ state: TradeState.VALID, trade: undefined })
})

View File

@@ -1,7 +1,9 @@
import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { useMemo } from 'react'
import { RouterPreference } from 'state/routing/slice'
import { InterfaceTrade, TradeState } from 'state/routing/types'
import { useRoutingAPITrade } from 'state/routing/useRoutingAPITrade'
import { useClientSideRouter } from 'state/user/hooks'
import useAutoRouterSupported from './useAutoRouterSupported'
import { useClientSideV3Trade } from './useClientSideV3Trade'
@@ -30,10 +32,12 @@ export function useBestTrade(
200
)
const [clientSideRouter] = useClientSideRouter()
const routingAPITrade = useRoutingAPITrade(
tradeType,
autoRouterSupported && isWindowVisible ? debouncedAmount : undefined,
debouncedOtherCurrency
debouncedOtherCurrency,
clientSideRouter ? RouterPreference.CLIENT : RouterPreference.API
)
const isLoading = routingAPITrade.state === TradeState.LOADING

View File

@@ -1,6 +1,7 @@
import { nanoid } from '@reduxjs/toolkit'
import { TokenList } from '@uniswap/token-lists'
import { MAINNET_PROVIDER } from 'constants/networks'
import { SupportedChainId } from 'constants/chains'
import { RPC_PROVIDERS } from 'constants/providers'
import getTokenList from 'lib/hooks/useTokenList/fetchTokenList'
import resolveENSContentHash from 'lib/utils/resolveENSContentHash'
import { useCallback } from 'react'
@@ -16,7 +17,9 @@ export function useFetchListCallback(): (listUrl: string, sendDispatch?: boolean
async (listUrl: string, sendDispatch = true) => {
const requestId = nanoid()
sendDispatch && dispatch(fetchTokenList.pending({ requestId, url: listUrl }))
return getTokenList(listUrl, (ensName: string) => resolveENSContentHash(ensName, MAINNET_PROVIDER))
return getTokenList(listUrl, (ensName: string) =>
resolveENSContentHash(ensName, RPC_PROVIDERS[SupportedChainId.MAINNET])
)
.then((tokenList) => {
sendDispatch && dispatch(fetchTokenList.fulfilled({ url: listUrl, tokenList, requestId }))
return tokenList

Some files were not shown because too many files have changed in this diff Show More