diff --git a/CODEOWNERS b/CODEOWNERS index 462cee42496..6b8a49be4a6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -30,6 +30,8 @@ src/components/unique-token @osdnk @terrysahaidak @jkadamczyk src/components/floating-emojis @osdnk @terrysahaidak @jkadamczyk src/components/buttons/hold-to-authorize @osdnk @terrysahaidak @jkadamczyk src/components/icons/svg/ProgressIcon.tsx @osdnk @terrysahaidak @jkadamczyk +src/components/large-countdown-clock @pugson @osdnk @terrysahaidak +src/components/step-indicator @pugson @osdnk @terrysahaidak # APP CONFIG src/config @osdnk @brunobar79 @skylarbarrera diff --git a/e2e/deeplinks.spec.js b/e2e/deeplinks.spec.js index 17ba61f89f2..85c37792ef6 100644 --- a/e2e/deeplinks.spec.js +++ b/e2e/deeplinks.spec.js @@ -44,7 +44,6 @@ describe('Deeplinks spec', () => { it('Should navigate to the Wallet screen after tapping on "Import Wallet"', async () => { await Helpers.disableSynchronization(); - await Helpers.checkIfVisible('wallet-info-input'); await Helpers.waitAndTap('wallet-info-submit-button'); if (device.getPlatform() === 'android') { await Helpers.checkIfVisible('pin-authentication-screen'); @@ -55,8 +54,6 @@ describe('Deeplinks spec', () => { } await Helpers.checkIfVisible('wallet-screen', 40000); await Helpers.enableSynchronization(); - // Waiting 10s for MATIC assets to show up - await Helpers.delay(20000); }); it('should be able to handle ethereum payments urls for ETH (mainnet)', async () => { diff --git a/e2e/discoverSheetFlow.spec.js b/e2e/discoverSheetFlow.spec.js index 321b1f8f262..3291d97d433 100644 --- a/e2e/discoverSheetFlow.spec.js +++ b/e2e/discoverSheetFlow.spec.js @@ -42,7 +42,13 @@ describe('Discover Sheet Flow', () => { await Helpers.enableSynchronization(); }); + it('Should navigate to the Profile screen after swiping right', async () => { + await Helpers.swipe('wallet-screen', 'right', 'slow'); + await Helpers.checkIfVisible('profile-screen'); + }); + it('Should navigate to Discover screen after tapping Discover Button', async () => { + await Helpers.swipe('profile-screen', 'left', 'slow'); await Helpers.waitAndTap('discover-button'); await Helpers.checkIfVisible('discover-header'); }); @@ -182,6 +188,62 @@ describe('Discover Sheet Flow', () => { await Helpers.waitAndTap('pools-list-oneDayVolumeUSD'); await Helpers.checkIfVisible('pools-section-oneDayVolumeUSD'); }); + + it('Should navigate to the Wallet screen after swiping right', async () => { + await Helpers.swipe('discover-home', 'down', 'slow'); + await Helpers.swipe('discover-sheet', 'right', 'slow'); + await Helpers.checkIfVisible('wallet-screen'); + }); + + it('Should navigate to the Profile screen after swiping right again', async () => { + await Helpers.swipe('wallet-screen', 'right', 'slow'); + await Helpers.checkIfVisible('profile-screen'); + }); + + it('Should navigate to Settings Modal after tapping Settings Button', async () => { + await Helpers.waitAndTap('settings-button'); + await Helpers.checkIfVisible('settings-modal'); + }); + + it('Should navigate to Developer Settings after tapping Developer Section', async () => { + await Helpers.waitAndTap('developer-section'); + await Helpers.checkIfVisible('developer-settings-modal'); + }); + + it('Should make ENS Profiles available', async () => { + await Helpers.swipe('developer-settings-modal', 'up', 'slow'); + await Helpers.tapByText('ENS Profiles'); + await Helpers.tapByText('Done'); + }); + + it('Should go to Discover screen', async () => { + await Helpers.swipe('profile-screen', 'left', 'slow'); + await Helpers.swipe('wallet-screen', 'left', 'slow'); + await Helpers.checkIfVisible('ens-register-name-banner'); + }); + + it('Should search and open Profile for rainbowwallet.eth', async () => { + await Helpers.waitAndTap('search-fab'); + await Helpers.typeText( + 'discover-search-input', + 'rainbowwallet.eth\n', + true + ); + await Helpers.checkIfVisible( + 'discover-currency-select-list-contact-row-rainbowwallet.eth' + ); + await Helpers.checkIfNotVisible( + 'discover-currency-select-list-exchange-coin-row-ETH' + ); + await Helpers.waitAndTap( + 'discover-currency-select-list-contact-row-rainbowwallet.eth' + ); + }); + + it('Should watch wallet from Profile sheet', async () => { + await Helpers.waitAndTap('profile-sheet-watch-button'); + }); + afterAll(async () => { // Reset the app state await device.clearKeychain(); diff --git a/e2e/hardhatTransactionFlow.spec.js b/e2e/hardhatTransactionFlow.spec.js index c010323e791..11dbaa6b8c8 100644 --- a/e2e/hardhatTransactionFlow.spec.js +++ b/e2e/hardhatTransactionFlow.spec.js @@ -3,11 +3,8 @@ /* eslint-disable jest/expect-expect */ import { exec } from 'child_process'; import { Contract } from '@ethersproject/contracts'; -import { JsonRpcProvider } from '@ethersproject/providers'; -import { Wallet } from '@ethersproject/wallet'; import WalletConnect from '@walletconnect/client'; import { convertUtf8ToHex } from '@walletconnect/utils'; -import { ethers } from 'ethers'; import * as Helpers from './helpers'; import kittiesABI from '@rainbow-me/references/cryptokitties-abi.json'; import erc20ABI from '@rainbow-me/references/erc20-abi.json'; @@ -17,26 +14,13 @@ let uri = null; let account = null; const RAINBOW_WALLET_DOT_ETH = '0x7a3d05c70581bD345fe117c06e45f9669205384f'; -const TESTING_WALLET = '0x3Cb462CDC5F809aeD0558FBEe151eD5dC3D3f608'; +const CRYPTOKITTIES_ADDRESS = '0x06012c8cf97BEaD5deAe237070F9587f8E7A266d'; const ETH_ADDRESS = 'eth'; const BAT_TOKEN_ADDRESS = '0x0d8775f648430679a709e98d2b0cb6250d2887ef'; -const CRYPTOKITTIES_ADDRESS = '0x06012c8cf97BEaD5deAe237070F9587f8E7A266d'; - -const getProvider = () => { - if (!getProvider._instance) { - getProvider._instance = new JsonRpcProvider( - device.getPlatform() === 'ios' - ? process.env.HARDHAT_URL_IOS - : process.env.HARDHAT_URL_ANDROID, - 'any' - ); - } - return getProvider._instance; -}; const isNFTOwner = async address => { - const provider = getProvider(); + const provider = Helpers.getProvider(); const kittiesContract = new Contract( CRYPTOKITTIES_ADDRESS, kittiesABI, @@ -47,7 +31,7 @@ const isNFTOwner = async address => { }; const getOnchainBalance = async (address, tokenContractAddress) => { - const provider = getProvider(); + const provider = Helpers.getProvider(); if (tokenContractAddress === ETH_ADDRESS) { const balance = await provider.getBalance(RAINBOW_WALLET_DOT_ETH); return balance; @@ -62,21 +46,6 @@ const getOnchainBalance = async (address, tokenContractAddress) => { } }; -const sendETHtoTestWallet = async () => { - const provider = getProvider(); - // Hardhat account 0 that has 10000 ETH - const wallet = new Wallet( - '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', - provider - ); - // Sending 20 ETH so we have enough to pay the tx fees even when the gas is too high - await wallet.sendTransaction({ - to: TESTING_WALLET, - value: ethers.utils.parseEther('20'), - }); - return true; -}; - beforeAll(async () => { // Connect to hardhat await exec('yarn hardhat'); @@ -125,6 +94,10 @@ describe('Hardhat Transaction Flow', () => { await Helpers.enableSynchronization(); }); + it('Should send ETH to test wallet"', async () => { + await Helpers.sendETHtoTestWallet(); + }); + it('Should navigate to the Profile screen after swiping right', async () => { await Helpers.swipe('wallet-screen', 'right', 'slow'); await Helpers.checkIfVisible('profile-screen'); @@ -155,8 +128,6 @@ describe('Hardhat Transaction Flow', () => { } it('Should show Hardhat Toast after pressing Connect To Hardhat', async () => { - await sendETHtoTestWallet(); - await Helpers.waitAndTap('hardhat-section'); await Helpers.checkIfVisible('testnet-toast-Hardhat'); await Helpers.swipe('profile-screen', 'left', 'slow'); @@ -206,7 +177,6 @@ describe('Hardhat Transaction Flow', () => { await Helpers.swipe('profile-screen', 'left', 'slow'); }); - it('Should show completed swap ETH -> ERC20 (DAI)', async () => { try { await Helpers.checkIfVisible('Swapped-Ethereum'); @@ -229,6 +199,9 @@ describe('Hardhat Transaction Flow', () => { ); await Helpers.waitAndTap('CryptoKitties-family-header'); await Helpers.tapByText('Arun Cattybinky'); + await Helpers.waitAndTap('gas-speed-custom'); + await Helpers.waitAndTap('speed-pill-urgent'); + await Helpers.waitAndTap('gas-speed-done-button'); await Helpers.waitAndTap('send-sheet-confirm-action-button', 20000); await Helpers.tapAndLongPress('send-confirmation-button'); await Helpers.checkIfVisible('profile-screen'); diff --git a/e2e/helpers.js b/e2e/helpers.js index f7792f38343..53f163bc320 100644 --- a/e2e/helpers.js +++ b/e2e/helpers.js @@ -1,4 +1,10 @@ +import { JsonRpcProvider } from '@ethersproject/providers'; +import { Wallet } from '@ethersproject/wallet'; import { expect } from 'detox'; +import { ethers } from 'ethers'; + +const TESTING_WALLET = '0x3Cb462CDC5F809aeD0558FBEe151eD5dC3D3f608'; + const DEFAULT_TIMEOUT = 8000; // eslint-disable-next-line eslint-comments/disable-enable-pair /* eslint-disable no-undef */ @@ -200,3 +206,30 @@ export function delay(ms) { }, ms); }); } + +export function getProvider() { + if (!getProvider._instance) { + getProvider._instance = new JsonRpcProvider( + device.getPlatform() === 'ios' + ? process.env.HARDHAT_URL_IOS + : process.env.HARDHAT_URL_ANDROID, + 'any' + ); + } + return getProvider._instance; +} + +export async function sendETHtoTestWallet() { + const provider = getProvider(); + // Hardhat account 0 that has 10000 ETH + const wallet = new Wallet( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + provider + ); + // Sending 20 ETH so we have enough to pay the tx fees even when the gas is too high + await wallet.sendTransaction({ + to: TESTING_WALLET, + value: ethers.utils.parseEther('20'), + }); + return true; +} diff --git a/e2e/init.js b/e2e/init.js index ed60b8b8d3c..797777a075c 100644 --- a/e2e/init.js +++ b/e2e/init.js @@ -3,6 +3,8 @@ require('dotenv').config({ path: '.env' }); beforeAll(async () => { + await device.clearKeychain(); + await device.launchApp(); await device.setURLBlacklist([ diff --git a/e2e/registerENSFlow.spec.js b/e2e/registerENSFlow.spec.js new file mode 100644 index 00000000000..0247003b417 --- /dev/null +++ b/e2e/registerENSFlow.spec.js @@ -0,0 +1,315 @@ +/* eslint-disable no-undef */ +/* eslint-disable jest/expect-expect */ +import { exec } from 'child_process'; +import { hash } from '@ensdomains/eth-ens-namehash'; +import { Contract } from '@ethersproject/contracts'; +import * as Helpers from './helpers'; +import registrarABI from '@rainbow-me/references/ens/ENSETHRegistrarController.json'; +import publicResolverABI from '@rainbow-me/references/ens/ENSPublicResolver.json'; +const ensETHRegistrarControllerAddress = + '0x283Af0B28c62C092C9727F1Ee09c02CA627EB7F5'; +const ensPublicResolverAddress = '0x4976fb03C32e5B8cfe2b6cCB31c09Ba78EBaBa41'; + +const RANDOM_NAME = 'somerandomname321'; +const RANDOM_NAME_ETH = RANDOM_NAME + '.eth'; +const RAINBOW_TEST_WALLET_NAME = 'rainbowtestwallet.eth'; +const RAINBOW_TEST_WALLET_ADDRESS = + '0x3Cb462CDC5F809aeD0558FBEe151eD5dC3D3f608'; +const RECORD_BIO = 'my bio'; +const RECORD_NAME = 'random'; +const EIP155_FORMATTED_AVATAR_RECORD = + 'eip155:1/erc721:0x06012c8cf97bead5deae237070f9587f8e7a266d/1368227'; + +const nameIsAvailable = async name => { + const provider = Helpers.getProvider(); + const registrarContract = new Contract( + ensETHRegistrarControllerAddress, + registrarABI, + provider + ); + const nameIsAvailable = await registrarContract.available(name); + return !!nameIsAvailable; +}; + +const getRecords = async ensName => { + const provider = Helpers.getProvider(); + const publicResolver = new Contract( + ensPublicResolverAddress, + publicResolverABI, + provider + ); + const hashName = hash(ensName); + const description = await publicResolver.text(hashName, 'description'); + const displayName = await publicResolver.text( + hashName, + 'me.rainbow.displayName' + ); + const avatar = await publicResolver.text(hashName, 'avatar'); + return { avatar, description, displayName }; +}; + +const resolveName = async ensName => { + const provider = Helpers.getProvider(); + const address = await provider.resolveName(ensName); + const primaryName = await provider.lookupAddress(address); + return { address, primaryName }; +}; + +const validatePrimaryName = async name => { + const { + address: rainbowAddress, + primaryName: rainbowPrimaryName, + } = await resolveName(RAINBOW_TEST_WALLET_NAME); + const { + address: randomAddress, + primaryName: randomPrimaryName, + } = await resolveName(RANDOM_NAME_ETH); + + if ( + rainbowAddress !== randomAddress || + rainbowAddress !== RAINBOW_TEST_WALLET_ADDRESS || + randomAddress !== RAINBOW_TEST_WALLET_ADDRESS + ) + throw new Error('Resolved address is wrong'); + + if ( + rainbowPrimaryName !== randomPrimaryName || + rainbowPrimaryName !== name || + randomPrimaryName !== name + ) + throw new Error('Resolved name is wrong'); +}; + +beforeAll(async () => { + // Connect to hardhat + await exec('yarn hardhat'); + await exec( + 'open /Applications/Xcode.app/Contents/Developer/Applications/Simulator.app/' + ); +}); + +describe('Register ENS Flow', () => { + it('Should show the welcome screen', async () => { + await Helpers.checkIfVisible('welcome-screen'); + }); + + it('Should show the "Restore Sheet" after tapping on "I already have a wallet"', async () => { + await Helpers.waitAndTap('already-have-wallet-button'); + await Helpers.checkIfExists('restore-sheet'); + }); + + it('show the "Import Sheet" when tapping on "Restore with a recovery phrase or private key"', async () => { + await Helpers.waitAndTap('restore-with-key-button'); + await Helpers.checkIfExists('import-sheet'); + }); + + it('Should show the "Add wallet modal" after tapping import with a valid seed"', async () => { + await Helpers.clearField('import-sheet-input'); + await Helpers.typeText('import-sheet-input', process.env.TEST_SEEDS, false); + await Helpers.checkIfElementHasString( + 'import-sheet-button-label', + 'Import' + ); + await Helpers.waitAndTap('import-sheet-button'); + await Helpers.checkIfVisible('wallet-info-modal'); + }); + + it('Should navigate to the Wallet screen after tapping on "Import Wallet"', async () => { + await Helpers.disableSynchronization(); + await Helpers.waitAndTap('wallet-info-submit-button'); + if (device.getPlatform() === 'android') { + await Helpers.checkIfVisible('pin-authentication-screen'); + // Set the pin + await Helpers.authenticatePin('1234'); + // Confirm it + await Helpers.authenticatePin('1234'); + } + await Helpers.checkIfVisible('wallet-screen', 80000); + await Helpers.enableSynchronization(); + }); + + it('Should send ETH to test wallet"', async () => { + await Helpers.sendETHtoTestWallet(); + }); + + it('Should navigate to the Profile screen after swiping right', async () => { + await Helpers.swipe('wallet-screen', 'right', 'slow'); + await Helpers.checkIfVisible('profile-screen'); + }); + + it('Should navigate to Settings Modal after tapping Settings Button', async () => { + await Helpers.waitAndTap('settings-button'); + await Helpers.checkIfVisible('settings-modal'); + }); + + it('Should navigate to Developer Settings after tapping Developer Section', async () => { + await Helpers.waitAndTap('developer-section'); + await Helpers.checkIfVisible('developer-settings-modal'); + }); + + it('Should make ENS Profiles available', async () => { + await Helpers.swipe('developer-settings-modal', 'up', 'slow'); + await Helpers.tapByText('ENS Profiles'); + }); + + it('Should show Hardhat Toast after pressing Connect To Hardhat', async () => { + await Helpers.waitAndTap('hardhat-section'); + await Helpers.checkIfVisible('testnet-toast-Hardhat'); + }); + + it('Should navigate to the Wallet screen after swiping left', async () => { + await Helpers.swipe('profile-screen', 'left', 'slow'); + await Helpers.checkIfVisible('wallet-screen'); + }); + + it('Should navigate to the Discover sheet screen after tapping Discover Button', async () => { + await Helpers.waitAndTap('discover-button'); + await Helpers.checkIfVisible('discover-header'); + }); + + it('Should go to ENS flow pressing the ENS banner', async () => { + await Helpers.waitAndTap('ens-register-name-banner'); + await Helpers.checkIfVisible('ens-intro-sheet'); + }); + + it('Should be able to press a profile and continue to the ENS search screen', async () => { + await Helpers.swipe('ens-names-marquee', 'left', 'slow'); + await Helpers.swipe('ens-names-marquee', 'right', 'slow'); + await Helpers.waitAndTap( + 'ens-intro-sheet-search-new-name-button-action-button' + ); + }); + + it('Should be able to type a name that is not available', async () => { + await Helpers.checkIfVisible('ens-search-input'); + await Helpers.typeText('ens-search-input', 'rainbowwallet', false); + await Helpers.delay(3000); + await Helpers.waitAndTap('ens-search-clear-button'); + }); + + it('Should be able to type a name that is available and wait for fees', async () => { + await Helpers.checkIfVisible('ens-search-input'); + await Helpers.typeText('ens-search-input', RANDOM_NAME, false); + }); + + it('Should be able to see network fees and name rent price', async () => { + await Helpers.checkIfVisible('ens-search-input'); + await Helpers.checkIfVisible('ens-registration-fees'); + await Helpers.checkIfVisible('ens-registration-price'); + }); + + it('Should go to view to set records', async () => { + await Helpers.checkIfVisible('ens-search-continue-action-button'); + await Helpers.waitAndTap('ens-search-continue-action-button'); + await Helpers.checkIfVisible('ens-text-record-me.rainbow.displayName'); + await Helpers.typeText( + 'ens-text-record-me.rainbow.displayName', + RECORD_NAME, + false + ); + await Helpers.tapByText('Got it'); + await Helpers.checkIfVisible('ens-text-record-description'); + await Helpers.typeText('ens-text-record-description', RECORD_BIO, false); + await Helpers.clearField('ens-text-record-me.rainbow.displayName'); + await Helpers.waitAndTap('use-select-image-avatar'); + await Helpers.tapByText('CryptoKitties'); + await Helpers.tapByText('Arun Cattybinky'); + await Helpers.checkIfVisible('ens-assign-records-review-action-button'); + await Helpers.waitAndTap('ens-assign-records-review-action-button'); + }); + + it('Should display change gas to Urgent', async () => { + await Helpers.waitAndTap('gas-speed-custom'); + await Helpers.waitAndTap('speed-pill-urgent'); + await Helpers.waitAndTap('gas-speed-done-button'); + }); + + it('Should go to review registration and start it', async () => { + await Helpers.checkIfVisible(`ens-transaction-action-COMMIT`); + await Helpers.waitAndTap(`ens-transaction-action-COMMIT`); + await Helpers.delay(5000); + await Helpers.checkIfVisible( + `ens-confirm-register-label-WAIT_ENS_COMMITMENT` + ); + await Helpers.delay(60000); + }); + + it('Should see confirm registration screen and set reverse records', async () => { + await Helpers.checkIfVisible(`ens-reverse-record-switch`); + // set RANDOM_NAME as primary name + await Helpers.waitAndTap('ens-reverse-record-switch'); + await Helpers.checkIfVisible(`ens-transaction-action-REGISTER`); + await Helpers.waitAndTap(`ens-transaction-action-REGISTER`); + }); + + it('Should confirm that the name is not available anymore', async () => { + await Helpers.delay(4000); + const ensAvailable = await nameIsAvailable(RANDOM_NAME); + if (ensAvailable) throw new Error('ENS name is available'); + }); + + it('Should confirm that the bio record is set', async () => { + const { description, displayName, avatar } = await getRecords( + RANDOM_NAME_ETH + ); + if (description !== RECORD_BIO) throw new Error('ENS description is wrong'); + if (displayName === RECORD_NAME) + throw new Error('ENS displayName is wrong'); + if (avatar !== EIP155_FORMATTED_AVATAR_RECORD) + throw new Error('ENS avatar is wrong'); + }); + + it('Should confirm RANDOM_NAME is primary name', async () => { + await Helpers.delay(3000); + await validatePrimaryName(RANDOM_NAME_ETH); + }); + + it('Should navigate to the Wallet screen and refresh', async () => { + await Helpers.swipe('profile-screen', 'left', 'slow'); + await Helpers.checkIfVisible('wallet-screen'); + await Helpers.swipe('wallet-screen', 'down', 'slow'); + }); + + it('Should open ENS rainbowtestwallet.eth', async () => { + await Helpers.swipe('wallet-screen', 'up', 'slow'); + await Helpers.tapByText('ENS'); + await Helpers.swipe('wallet-screen', 'up', 'slow'); + await Helpers.waitAndTap('wrapped-nft-rainbowtestwallet.eth'); + }); + + it('Should use rainbowtestwallet.eth as primary name', async () => { + await Helpers.swipe('unique-token-expanded-state', 'up', 'slow'); + await Helpers.waitAndTap('ens-reverse-record-switch'); + await Helpers.checkIfVisible(`ens-transaction-action-SET_NAME`); + await Helpers.delay(3000); + await Helpers.waitAndTap(`ens-transaction-action-SET_NAME`); + }); + + it('Should confirm rainbowtestwallet.eth is primary name', async () => { + await Helpers.delay(3000); + await validatePrimaryName(RAINBOW_TEST_WALLET_NAME); + }); + + it('Should navigate to the Wallet screen to renew', async () => { + await Helpers.swipe('profile-screen', 'left', 'slow'); + await Helpers.checkIfVisible('wallet-screen'); + }); + + it('Should open ENS rainbowtestwallet.eth to renew', async () => { + await Helpers.swipe('wallet-screen', 'up', 'slow'); + await Helpers.waitAndTap('wrapped-nft-rainbowtestwallet.eth'); + }); + + it('Should renew rainbowtestwallet.eth', async () => { + await Helpers.waitAndTap('unique-token-expanded-state-extend-duration'); + await Helpers.checkIfVisible(`ens-transaction-action-RENEW`); + await Helpers.waitAndTap(`ens-transaction-action-RENEW`); + }); + + afterAll(async () => { + // Reset the app state + await device.clearKeychain(); + await exec('kill $(lsof -t -i:8545)'); + await Helpers.delay(2000); + }); +}); diff --git a/e2e/sendSheetFlow.spec.js b/e2e/sendSheetFlow.spec.js index 72a090c2113..e810593b924 100644 --- a/e2e/sendSheetFlow.spec.js +++ b/e2e/sendSheetFlow.spec.js @@ -244,7 +244,7 @@ describe('Send Sheet Interaction Flow', () => { it('Should show Add Contact Screen after tapping Add Contact Button', async () => { await Helpers.checkIfVisible('add-contact-button'); await Helpers.waitAndTap('add-contact-button'); - await Helpers.checkIfVisible('contact-profile-name-input'); + await Helpers.checkIfVisible('wallet-info-input'); }); it('Should do nothing on Add Contact cancel', async () => { @@ -256,9 +256,9 @@ describe('Send Sheet Interaction Flow', () => { it('Should update address field to show contact name & show edit contact button', async () => { await Helpers.waitAndTap('add-contact-button'); - await Helpers.clearField('contact-profile-name-input'); - await Helpers.typeText('contact-profile-name-input', 'testcoin.test', true); - await Helpers.waitAndTap('contact-profile-add-button'); + await Helpers.clearField('wallet-info-input'); + await Helpers.typeText('wallet-info-input', 'testcoin.test', true); + await Helpers.waitAndTap('wallet-info-submit-button'); await Helpers.checkIfElementByTextIsVisible('testcoin.test'); await Helpers.checkIfVisible('edit-contact-button'); }); @@ -273,9 +273,10 @@ describe('Send Sheet Interaction Flow', () => { await Helpers.checkIfVisible('edit-contact-button'); await Helpers.waitAndTap('edit-contact-button'); await Helpers.tapByText('Edit Contact'); - await Helpers.clearField('contact-profile-name-input'); - await Helpers.typeText('contact-profile-name-input', 'testcoin.eth', true); - await Helpers.tapByText('Done'); + await Helpers.clearField('wallet-info-input'); + await Helpers.typeText('wallet-info-input', 'testcoin.eth', true); + await Helpers.waitAndTap('wallet-info-submit-button'); + // await Helpers.tapByText('Done'); await Helpers.checkIfElementByTextIsVisible('testcoin.eth'); }); diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 475a094be18..023ead4ace1 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -383,7 +383,7 @@ PODS: - React - react-native-get-random-values (1.5.0): - React - - react-native-ios-context-menu (1.3.0): + - react-native-ios-context-menu (1.7.4): - React-Core - react-native-mail (4.1.0): - React @@ -538,12 +538,12 @@ PODS: - React - RNGestureHandler (1.10.3): - React-Core - - RNImageCropPicker (0.32.3): + - RNImageCropPicker (0.37.3): - React-Core - React-RCTImage - - RNImageCropPicker/QBImagePickerController (= 0.32.3) + - RNImageCropPicker/QBImagePickerController (= 0.37.3) - TOCropViewController - - RNImageCropPicker/QBImagePickerController (0.32.3): + - RNImageCropPicker/QBImagePickerController (0.37.3): - React-Core - React-RCTImage - TOCropViewController @@ -1048,7 +1048,7 @@ SPEC CHECKSUMS: react-native-cameraroll: 2957f2bce63ae896a848fbe0d5352c1bd4d20866 react-native-cloud-fs: 1e883ec82ff418d320c128f61971dd971b128ca0 react-native-get-random-values: 1404bd5cc0ab0e287f75ee1c489555688fc65f89 - react-native-ios-context-menu: 1ccf4d6aa1c0e7dc596613eb9a36f8c0c23ca56f + react-native-ios-context-menu: 7bf49ec6006cc0c61d873419557b85eb1b9629fb react-native-mail: a864fb211feaa5845c6c478a3266de725afdce89 react-native-minimizer: b94809a769ac3825b46fd081d4f0ae2560791536 react-native-mmkv: 30297e5e2a65341b36a727c1c79b0aff0132b396 @@ -1096,7 +1096,7 @@ SPEC CHECKSUMS: RNFBRemoteConfig: e62dfa4e1bcbfde05f04acb5257eb9e64d08614a RNFS: 2bd9eb49dc82fa9676382f0585b992c424cd59df RNGestureHandler: a479ebd5ed4221a810967000735517df0d2db211 - RNImageCropPicker: 54e5ea3d0e298ed51e1441a2fa0a0e7c90ee256d + RNImageCropPicker: 44e2807bc410741f35d4c45b6586aedfe3da39d2 RNInputMask: 815461ebdf396beb62cf58916c35cf6930adb991 RNKeyboard: ceec456427493b286539f40442620881b5840dff RNKeychain: 4f63aada75ebafd26f4bc2c670199461eab85d94 diff --git a/ios/TransactionListView.swift b/ios/TransactionListView.swift index 3d1a005155c..b91d29fa4d5 100644 --- a/ios/TransactionListView.swift +++ b/ios/TransactionListView.swift @@ -141,7 +141,10 @@ class TransactionListView: UIView, UITableViewDelegate, UITableViewDataSource { @objc var accountImage: NSString? = nil { didSet { if (accountImage != nil) { - let url = URL.init(fileURLWithPath: accountImage!.expandingTildeInPath) + var url: URL = URL.init(string: accountImage! as String)! + if !UIApplication.shared.canOpenURL(url) { + url = URL.init(fileURLWithPath: accountImage!.expandingTildeInPath) + } if let imageData:NSData = NSData(contentsOf: url) { let image = UIImage(data: imageData as Data) header.accountImage.alpha = 1.0 diff --git a/package.json b/package.json index 88605acd4a1..b3d56995042 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,10 @@ "dependencies": { "@apollo/client": "3.2.4", "@bankify/react-native-animate-number": "0.2.1", + "@bradgarropy/use-countdown": "1.4.1", "@capsizecss/core": "3.0.0", "@ensdomains/address-encoder": "0.2.16", + "@ensdomains/content-hash": "2.5.7", "@ensdomains/eth-ens-namehash": "2.0.15", "@ethersproject/abi": "5.6.3", "@ethersproject/abstract-provider": "5.6.1", @@ -105,6 +107,7 @@ "@types/i18n-js": "3.0.3", "@types/lodash": "4.14.168", "@types/react-redux": "7.1.9", + "@types/url-join": "4.0.1", "@uniswap/sdk": "3.0.3", "@uniswap/v2-core": "1.0.1", "@unstoppabledomains/resolution": "7.1.4", @@ -114,6 +117,7 @@ "assert": "1.5.0", "async-mutex": "0.3.2", "asyncstorage-down": "4.2.0", + "big-integer": "1.6.51", "bignumber.js": "9.0.1", "bip39": "3.0.2", "browserify-zlib": "0.1.4", @@ -138,6 +142,7 @@ "ethereumjs-util": "6.2.1", "ethereumjs-wallet": "1.0.1", "events": "1.1.1", + "fast-text-encoding": "1.0.3", "global": "4.4.0", "grapheme-splitter": "1.0.4", "graphql-tag": "2.11.0", @@ -158,6 +163,7 @@ "make-color-more-chill": "0.2.2", "match-sorter": "6.3.0", "mnemonist": "0.38.1", + "multiformats": "9.6.2", "nanoid": "3.2.0", "p-wait-for": "4.1.0", "pako": "2.0.4", @@ -197,9 +203,9 @@ "react-native-gesture-handler": "1.10.3", "react-native-get-random-values": "1.5.0", "react-native-haptic-feedback": "1.11.0", - "react-native-image-crop-picker": "0.32.3", + "react-native-image-crop-picker": "0.37.3", "react-native-indicators": "0.17.0", - "react-native-ios-context-menu": "1.3.0", + "react-native-ios-context-menu": "1.7.4", "react-native-ios11-devicecheck": "0.0.3", "react-native-iphone-x-helper": "1.3.0", "react-native-keyboard-area": "1.0.5", @@ -260,6 +266,7 @@ "timers-browserify": "1.4.2", "tty-browserify": "0.0.0", "url": "0.10.3", + "url-join": "4.0.1", "url-parse": "1.5.10", "use-debounce": "5.2.0", "use-deep-compare": "1.1.0", @@ -286,6 +293,7 @@ "@types/jest": "26.0.14", "@types/mocha": "9.0.0", "@types/node": "16.11.6", + "@types/qs": "6.9.7", "@types/react": "16.9.52", "@types/react-native": "0.63.25", "@types/react-native-dotenv": "0.2.0", @@ -503,7 +511,8 @@ "bufferutil": false, "utf-8-validate": false, "postinstall-postinstall": false, - "core-js-pure": false + "core-js-pure": false, + "multiformats": false } }, "typeCoverage": { @@ -517,6 +526,7 @@ "ios/**/*.*", "e2e/**/*.*", "rainbow-scripts/**/*.*", + "src/ens-avatar/**/*.*", "src/react-native-animated-charts/**/*.*", "src/react-native-cool-modals/**/*.*", "src/react-native-shadow-stack/**/*.*", diff --git a/patches/react-native-fast-image+8.5.11.patch b/patches/react-native-fast-image+8.5.11.patch index 3f6636043d7..f8146bf58fa 100644 --- a/patches/react-native-fast-image+8.5.11.patch +++ b/patches/react-native-fast-image+8.5.11.patch @@ -10,3 +10,162 @@ index db0fada..54d8d5b 100644 + s.dependency 'SDWebImage', '~> 5.12.5' s.dependency 'SDWebImageWebPCoder', '~> 0.8.4' end +diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageSource.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageSource.java +index 888b38e..bb7ffc2 100644 +--- a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageSource.java ++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageSource.java +@@ -107,6 +107,12 @@ public class FastImageSource extends ImageSource { + } + + public GlideUrl getGlideUrl() { ++ Uri uriVal = getUri(); ++ ++ if (Uri.EMPTY.equals(uriVal)) { ++ return null; ++ } ++ + return new GlideUrl(getUri().toString(), getHeaders()); + } + } +diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewModule.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewModule.java +index 019032b..99c1e88 100644 +--- a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewModule.java ++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewModule.java +@@ -1,9 +1,15 @@ + package com.dylanvann.fastimage; + + import android.app.Activity; ++import android.support.annotation.Nullable; + + import com.bumptech.glide.Glide; ++import com.bumptech.glide.load.DataSource; ++import com.bumptech.glide.load.engine.GlideException; + import com.bumptech.glide.load.model.GlideUrl; ++import com.bumptech.glide.request.RequestListener; ++import com.bumptech.glide.request.target.Target; ++import com.facebook.react.bridge.Promise; + import com.facebook.react.bridge.Promise; + import com.facebook.react.bridge.ReactApplicationContext; + import com.facebook.react.bridge.ReactContextBaseJavaModule; +@@ -12,9 +18,12 @@ import com.facebook.react.bridge.ReadableArray; + import com.facebook.react.bridge.ReadableMap; + import com.facebook.react.views.imagehelper.ImageSource; + ++import java.io.File; ++ + class FastImageViewModule extends ReactContextBaseJavaModule { + + private static final String REACT_CLASS = "FastImageView"; ++ private static final String ERROR_LOAD_FAILED = "ERROR_LOAD_FAILED"; + + FastImageViewModule(ReactApplicationContext reactContext) { + super(reactContext); +@@ -83,4 +92,43 @@ class FastImageViewModule extends ReactContextBaseJavaModule { + Glide.get(activity.getApplicationContext()).clearDiskCache(); + promise.resolve(null); + } ++ ++ @ReactMethod ++ public void getCachePath(final ReadableMap source, final Promise promise) { ++ final Activity activity = getCurrentActivity(); ++ if (activity == null) return; ++ ++ activity.runOnUiThread(new Runnable() { ++ @Override ++ public void run() { ++ final FastImageSource imageSource = FastImageViewConverter.getImageSource(activity, source); ++ final GlideUrl glideUrl = imageSource.getGlideUrl(); ++ ++ if (glideUrl == null) { ++ promise.resolve(null); ++ return; ++ } ++ ++ Glide ++ .with(activity.getApplicationContext()) ++ .asFile() ++ .load(glideUrl) ++ .apply(FastImageViewConverter.getOptions(activity, imageSource, source)) ++ .listener(new RequestListener() { ++ @Override ++ public boolean onLoadFailed(@Nullable GlideException e, Object model, Target target, boolean isFirstResource) { ++ promise.reject(ERROR_LOAD_FAILED, e); ++ return false; ++ } ++ ++ @Override ++ public boolean onResourceReady(File resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { ++ promise.resolve(resource.getAbsolutePath()); ++ return false; ++ } ++ }) ++ .submit(); ++ } ++ }); ++ } + } +diff --git a/node_modules/react-native-fast-image/dist/index.cjs.js b/node_modules/react-native-fast-image/dist/index.cjs.js +index 2a49562..456e53c 100644 +--- a/node_modules/react-native-fast-image/dist/index.cjs.js ++++ b/node_modules/react-native-fast-image/dist/index.cjs.js +@@ -95,6 +95,8 @@ FastImage.priority = priority; + + FastImage.preload = sources => FastImageViewNativeModule.preload(sources); + ++FastImage.getCachePath = (source: Source) => FastImageViewNativeModule.getCachePath(source) ++ + FastImage.clearMemoryCache = () => FastImageViewNativeModule.clearMemoryCache(); + + FastImage.clearDiskCache = () => FastImageViewNativeModule.clearDiskCache(); +diff --git a/node_modules/react-native-fast-image/dist/index.d.ts b/node_modules/react-native-fast-image/dist/index.d.ts +index 8a91257..58af9b8 100644 +--- a/node_modules/react-native-fast-image/dist/index.d.ts ++++ b/node_modules/react-native-fast-image/dist/index.d.ts +@@ -95,6 +95,7 @@ export interface FastImageStaticProperties { + priority: typeof priority; + cacheControl: typeof cacheControl; + preload: (sources: Source[]) => void; ++ getCachePath: (source: Source) => Promise + clearMemoryCache: () => Promise; + clearDiskCache: () => Promise; + } +diff --git a/node_modules/react-native-fast-image/dist/index.js b/node_modules/react-native-fast-image/dist/index.js +index 1fc0e9d..91b4033 100644 +--- a/node_modules/react-native-fast-image/dist/index.js ++++ b/node_modules/react-native-fast-image/dist/index.js +@@ -88,6 +88,8 @@ FastImage.priority = priority; + + FastImage.preload = sources => FastImageViewNativeModule.preload(sources); + ++FastImage.getCachePath = (source: Source) => FastImageViewNativeModule.getCachePath(source) ++ + FastImage.clearMemoryCache = () => FastImageViewNativeModule.clearMemoryCache(); + + FastImage.clearDiskCache = () => FastImageViewNativeModule.clearDiskCache(); +diff --git a/node_modules/react-native-fast-image/ios/FastImage/FFFastImageViewManager.m b/node_modules/react-native-fast-image/ios/FastImage/FFFastImageViewManager.m +index a8059af..442a629 100644 +--- a/node_modules/react-native-fast-image/ios/FastImage/FFFastImageViewManager.m ++++ b/node_modules/react-native-fast-image/ios/FastImage/FFFastImageViewManager.m +@@ -35,6 +35,22 @@ - (FFFastImageView*)view { + [[SDWebImagePrefetcher sharedImagePrefetcher] prefetchURLs:urls]; + } + ++RCT_EXPORT_METHOD(getCachePath:(nonnull FFFastImageSource *)source ++ withResolver:(RCTPromiseResolveBlock)resolve ++ andRejecter:(RCTPromiseRejectBlock)reject) ++{ ++ SDWebImageManager *imageManager = [SDWebImageManager sharedManager]; ++ NSString *key = [imageManager cacheKeyForURL:source.url]; ++ BOOL isCached = [[SDImageCache sharedImageCache] diskImageDataExistsWithKey:key]; ++ ++ if (isCached) { ++ NSString *cachePath = [[SDImageCache sharedImageCache] cachePathForKey:key]; ++ resolve(cachePath); ++ } else { ++ resolve([NSNull null]); ++ } ++} ++ + RCT_EXPORT_METHOD(clearMemoryCache:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) + { + [SDImageCache.sharedImageCache clearMemory]; diff --git a/patches/react-native-image-crop-picker+0.37.3.patch b/patches/react-native-image-crop-picker+0.37.3.patch new file mode 100644 index 00000000000..eb62367c799 --- /dev/null +++ b/patches/react-native-image-crop-picker+0.37.3.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/react-native-image-crop-picker/ios/src/ImageCropPicker.m b/node_modules/react-native-image-crop-picker/ios/src/ImageCropPicker.m +index 7101410..272736e 100644 +--- a/node_modules/react-native-image-crop-picker/ios/src/ImageCropPicker.m ++++ b/node_modules/react-native-image-crop-picker/ios/src/ImageCropPicker.m +@@ -357,7 +357,7 @@ - (BOOL)cleanTmpDirectory { + } + } + +- [imagePickerController setModalPresentationStyle: UIModalPresentationFullScreen]; ++ [imagePickerController setModalPresentationStyle: UIModalPresentationOverFullScreen]; + [[self getRootVC] presentViewController:imagePickerController animated:YES completion:nil]; + }); + }]; diff --git a/patches/react-native-ios-context-menu+1.3.0.patch b/patches/react-native-ios-context-menu+1.3.0.patch deleted file mode 100644 index 5fed4f9b956..00000000000 --- a/patches/react-native-ios-context-menu+1.3.0.patch +++ /dev/null @@ -1,73 +0,0 @@ -diff --git a/node_modules/react-native-ios-context-menu/ios/src_library/React Native/RCTContextMenu/RCTMenuActionItem.swift b/node_modules/react-native-ios-context-menu/ios/src_library/React Native/RCTContextMenu/RCTMenuActionItem.swift -index 35ad66b..86d54b4 100644 ---- a/node_modules/react-native-ios-context-menu/ios/src_library/React Native/RCTContextMenu/RCTMenuActionItem.swift -+++ b/node_modules/react-native-ios-context-menu/ios/src_library/React Native/RCTContextMenu/RCTMenuActionItem.swift -@@ -127,14 +127,26 @@ extension RCTMenuActionItem { - print("RCTMenuActionItem, makeUIAction..."); - #endif - -- return UIAction( -- title : self.actionTitle, -- image : self.icon.image , -- identifier: self.identifier , -- discoverabilityTitle: self.discoverabilityTitle, -- attributes: self.UIMenuElementAttributes, -- state : self.UIMenuElementState, -- handler : { handler(self.dictionary, $0) } -- ); -+ if #available(iOS 15.0, *) { -+ return UIAction( -+ title : self.actionTitle, -+ subtitle : self.discoverabilityTitle, -+ image : self.icon.image, -+ identifier: self.identifier, -+ attributes: self.UIMenuElementAttributes, -+ state : self.UIMenuElementState, -+ handler : { handler(self.dictionary, $0) } -+ ) -+ } else { -+ return UIAction( -+ title : self.actionTitle, -+ image : self.icon.image, -+ identifier: self.identifier, -+ discoverabilityTitle : self.discoverabilityTitle, -+ attributes: self.UIMenuElementAttributes, -+ state : self.UIMenuElementState, -+ handler : { handler(self.dictionary, $0) } -+ ) -+ }; - }; - }; -diff --git a/node_modules/react-native-ios-context-menu/src/ContextMenuButton.ios.js b/node_modules/react-native-ios-context-menu/src/ContextMenuButton.ios.js -index f972d19..ed2a9c2 100644 ---- a/node_modules/react-native-ios-context-menu/src/ContextMenuButton.ios.js -+++ b/node_modules/react-native-ios-context-menu/src/ContextMenuButton.ios.js -@@ -1,6 +1,7 @@ - import React from 'react'; --import { StyleSheet, Platform, requireNativeComponent, UIManager, View, TouchableOpacity } from 'react-native'; -+import { StyleSheet, Platform, requireNativeComponent, UIManager, View } from 'react-native'; - import Proptypes from 'prop-types'; -+import { TouchableOpacity } from 'react-native-gesture-handler' - - import { ActionSheetFallback } from './functions/ActionSheetFallback'; - import { ContextMenuView } from './ContextMenuView'; -@@ -193,7 +194,7 @@ export class ContextMenuButton extends React.PureComponent { - }; - - render(){ -- const { useActionSheetFallback } = this.props; -+ const { useActionSheetFallback, isMenuPrimaryAction } = this.props; - const useContextMenu = - (isContextMenuViewSupported && !useActionSheetFallback); - -@@ -201,7 +202,9 @@ export class ContextMenuButton extends React.PureComponent { - useContextMenu? this._renderContextMenuView() : - useActionSheetFallback? ( - diff --git a/patches/react-native-ios-context-menu+1.7.4.patch b/patches/react-native-ios-context-menu+1.7.4.patch new file mode 100644 index 00000000000..db6dac6474c --- /dev/null +++ b/patches/react-native-ios-context-menu+1.7.4.patch @@ -0,0 +1,23 @@ +diff --git a/node_modules/react-native-ios-context-menu/src/components/ContextMenuButton/ContextMenuButton.tsx b/node_modules/react-native-ios-context-menu/src/components/ContextMenuButton/ContextMenuButton.tsx +index e545806..4fbb9ac 100644 +--- a/node_modules/react-native-ios-context-menu/src/components/ContextMenuButton/ContextMenuButton.tsx ++++ b/node_modules/react-native-ios-context-menu/src/components/ContextMenuButton/ContextMenuButton.tsx +@@ -1,5 +1,6 @@ + import React from 'react'; +-import { StyleSheet, View, TouchableOpacity, UIManager, findNodeHandle } from 'react-native'; ++import { StyleSheet, View, UIManager, findNodeHandle } from 'react-native'; ++import { TouchableOpacity } from 'react-native-gesture-handler' + + import { RNIContextMenuButton, RNIContextMenuButtonBaseProps, RNIContextMenuButtonCommands } from '../../native_components/RNIContextMenuButton'; + +@@ -211,7 +212,9 @@ export class ContextMenuButton extends React.PureComponent diff --git a/shim.js b/shim.js index 67fb4651271..779230216dd 100644 --- a/shim.js +++ b/shim.js @@ -8,6 +8,8 @@ import Storage from 'react-native-storage'; import toLocaleStringPolyfill from '@rainbow-me/helpers/toLocaleStringPolyfill'; import logger from 'logger'; +if (typeof BigInt === 'undefined') global.BigInt = require('big-integer'); + if (typeof btoa === 'undefined') { global.btoa = function (str) { return new Buffer(str, 'binary').toString('base64'); diff --git a/src/App.js b/src/App.js index 8f6e3bf489d..0a994aaebe0 100644 --- a/src/App.js +++ b/src/App.js @@ -77,7 +77,6 @@ import { InitialRouteContext } from '@rainbow-me/navigation/initialRoute'; import Routes from '@rainbow-me/routes'; import logger from 'logger'; import { Portal } from 'react-native-cool-modals/Portal'; - const WALLETCONNECT_SYNC_DELAY = 500; const FedoraToastRef = createRef(); @@ -133,7 +132,6 @@ if (__DEV__) { }; Sentry.init(sentryOptions); } - initSentryAndCheckForFedoraMode(); } diff --git a/src/apollo/queries.js b/src/apollo/queries.ts similarity index 62% rename from src/apollo/queries.js rename to src/apollo/queries.ts index 086ca253c86..83790a0740e 100644 --- a/src/apollo/queries.js +++ b/src/apollo/queries.ts @@ -1,6 +1,9 @@ import gql from 'graphql-tag'; -export const UNISWAP_PAIR_DATA_QUERY_VOLUME = (pairAddress, block) => { +export const UNISWAP_PAIR_DATA_QUERY_VOLUME = ( + pairAddress: string, + block: number +) => { const queryString = ` fragment PairFields on Pair { volumeUSD @@ -45,7 +48,10 @@ export const COMPOUND_ACCOUNT_AND_MARKET_QUERY = gql` } `; -export const UNISWAP_24HOUR_PRICE_QUERY = (tokenAddress, block) => { +export const UNISWAP_24HOUR_PRICE_QUERY = ( + tokenAddress: string, + block: number +) => { const queryString = ` query tokens { tokens(${ @@ -81,9 +87,9 @@ export const UNISWAP_ADDITIONAL_POOL_DATA = gql` } `; -export const GET_BLOCKS_QUERY = timestamps => { +export const GET_BLOCKS_QUERY = (timestamps: any) => { let queryString = 'query blocks {'; - queryString += timestamps.map(timestamp => { + queryString += timestamps.map((timestamp: any) => { return `t${timestamp}:blocks(first: 1, orderBy: timestamp, orderDirection: desc, where: { timestamp_gt: ${timestamp}, timestamp_lt: ${ timestamp + 600 } }) { @@ -193,14 +199,20 @@ export const ENS_SUGGESTIONS = gql` query lookup($name: String!, $amount: Int!) { domains( first: $amount - where: { name_contains: $name, resolvedAddress_not: null } + where: { name_starts_with: $name, resolvedAddress_not: null } + orderBy: labelName + orderDirection: asc ) { name resolver { + texts addr { id } } + owner { + id + } } } `; @@ -242,6 +254,119 @@ export const ENS_REGISTRATIONS = gql` } `; +export type EnsAccountRegistratonsData = { + account: { + registrations: { + domain: { + name: string; + labelhash: string; + owner: { + id: string; + }; + }; + }[]; + }; +}; + +export const ENS_ALL_ACCOUNT_REGISTRATIONS = gql` + query getAccountRegistrations($address: String!) { + account(id: $address) { + registrations(orderBy: registrationDate) { + domain { + name + owner { + id + } + } + } + } + } +`; + +export const ENS_ACCOUNT_REGISTRATIONS = gql` + query getAccountRegistrations( + $address: String! + $registrationDate_gt: BigInt = "0" + ) { + account(id: $address) { + registrations( + first: 99 + orderBy: registrationDate + orderDirection: desc + where: { registrationDate_gt: $registrationDate_gt } + ) { + domain { + name + labelhash + owner { + id + } + } + } + } + } +`; + +export type EnsGetRegistrationData = { + registration: { + id: string; + registrationDate: number; + expiryDate: number; + registrant: { + id: string; + }; + }; +}; + +export const ENS_GET_REGISTRATION = gql` + query getRegistration($id: ID!) { + registration(id: $id) { + id + registrationDate + expiryDate + registrant { + id + } + } + } +`; + +export type EnsGetRecordsData = { + domains: { + resolver: { + texts: string[]; + }; + }[]; +}; + +export const ENS_GET_RECORDS = gql` + query lookup($name: String!) { + domains(first: 1, where: { name: $name }) { + resolver { + texts + } + } + } +`; + +export type EnsGetCoinTypesData = { + domains: { + resolver: { + coinTypes: number[]; + }; + }[]; +}; + +export const ENS_GET_COIN_TYPES = gql` + query lookup($name: String!) { + domains(first: 1, where: { name: $name }) { + resolver { + coinTypes + } + } + } +`; + export const CONTRACT_FUNCTION = gql` query contractFunction($chainID: Int!, $hex: String!) { contractFunction(chainID: $chainID, hex: $hex) { @@ -249,3 +374,17 @@ export const CONTRACT_FUNCTION = gql` } } `; + +export type EnsGetNameFromLabelhash = { + domains: { + labelName: string; + }[]; +}; + +export const ENS_GET_NAME_FROM_LABELHASH = gql` + query lookup($labelhash: String!) { + domains(first: 1, where: { labelhash: $labelhash }) { + labelName + } + } +`; diff --git a/src/components/Spinner.js b/src/components/Spinner.tsx similarity index 78% rename from src/components/Spinner.js rename to src/components/Spinner.tsx index 727b9cf78ca..3b1f56f30d7 100644 --- a/src/components/Spinner.js +++ b/src/components/Spinner.tsx @@ -1,5 +1,5 @@ -import PropTypes from 'prop-types'; import React from 'react'; +// @ts-expect-error import { IS_TESTING } from 'react-native-dotenv'; import SpinnerImageSource from '../assets/spinner.png'; import { useTheme } from '../context/ThemeContext'; @@ -8,7 +8,18 @@ import { Centered } from './layout'; import { ImgixImage } from '@rainbow-me/images'; import { position } from '@rainbow-me/styles'; -const Spinner = ({ color = '', duration = 1500, size = 20, ...props }) => { +type SpinnerProps = { + color?: string; + duration?: number; + size?: 'small' | 'large' | number; +}; + +const Spinner = ({ + color = '', + duration = 1500, + size = 20, + ...props +}: SpinnerProps) => { const { colors } = useTheme(); let style; @@ -28,7 +39,7 @@ const Spinner = ({ color = '', duration = 1500, size = 20, ...props }) => { {IS_TESTING !== 'true' && ( @@ -38,10 +49,4 @@ const Spinner = ({ color = '', duration = 1500, size = 20, ...props }) => { ); }; -Spinner.propTypes = { - color: PropTypes.string, - duration: PropTypes.number, - size: PropTypes.number, -}; - export default React.memo(Spinner); diff --git a/src/components/animations/CheckmarkAnimation.tsx b/src/components/animations/CheckmarkAnimation.tsx new file mode 100644 index 00000000000..ff01279aad3 --- /dev/null +++ b/src/components/animations/CheckmarkAnimation.tsx @@ -0,0 +1,228 @@ +import React from 'react'; +import RadialGradient from 'react-native-radial-gradient'; +import Animated, { + useAnimatedStyle, + useDerivedValue, + withDelay, + withRepeat, + withSequence, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import { LargeCheckmarkIcon } from '../icons/svg/LargeCheckmarkIcon'; +import { Box } from '@rainbow-me/design-system'; +import { colors } from '@rainbow-me/styles'; + +export function CheckmarkAnimation() { + const circleEntering = () => { + 'worklet'; + const animations = { + opacity: withTiming(1, { duration: 250 }), + transform: [ + { + scale: withSpring(1, { + damping: 12, + restDisplacementThreshold: 0.001, + restSpeedThreshold: 0.001, + stiffness: 260, + }), + }, + ], + }; + const initialValues = { + opacity: 0, + transform: [{ scale: 0.4 }], + }; + return { + animations, + initialValues, + }; + }; + + const checkEntering = () => { + 'worklet'; + const animations = { + opacity: withDelay(200, withTiming(1, { duration: 200 })), + transform: [ + { + rotateZ: withDelay( + 250, + withSpring(`0deg`, { + damping: 10, + restDisplacementThreshold: 0.001, + restSpeedThreshold: 0.001, + stiffness: 280, + }) + ), + }, + { + scale: withDelay( + 250, + withSpring(1, { + damping: 12, + restDisplacementThreshold: 0.001, + restSpeedThreshold: 0.001, + stiffness: 280, + }) + ), + }, + ], + }; + const initialValues = { + opacity: 0, + transform: [{ rotateZ: '22deg' }, { scale: 0.5 }], + }; + return { + animations, + initialValues, + }; + }; + + const pulsingCheckmarkAnimation = useDerivedValue(() => + withDelay( + 2000, + withRepeat( + withSequence( + withDelay(2000, withTiming(1)), + withTiming(1.1), + withTiming(1) + ), + -1 + ) + ) + ); + const pulsingCircleAnimation = useDerivedValue(() => + withDelay( + 1800, + withRepeat( + withSequence( + withDelay(2000, withTiming(1)), + withTiming(1.05), + withTiming(1) + ), + -1 + ) + ) + ); + const rippleCircleAnimation = useDerivedValue(() => + withDelay( + 1800, + withRepeat( + withSequence( + withDelay(2000, withTiming(0)), + withTiming(1), + withTiming(0) + ), + -1 + ) + ) + ); + const rippleCircleScaleAnimation = useDerivedValue(() => + withDelay( + 1800, + withRepeat( + withSequence( + withDelay(2000, withTiming(0.6)), + withTiming(1), + withTiming(0.6) + ), + -1 + ) + ) + ); + const pulseCheckmarkStyle = useAnimatedStyle(() => ({ + transform: [{ scale: pulsingCheckmarkAnimation.value }], + })); + const pulseCircleStyle = useAnimatedStyle(() => ({ + transform: [{ scale: pulsingCircleAnimation.value }], + })); + const rippleCircleStyle = useAnimatedStyle(() => ({ + opacity: rippleCircleAnimation.value, + transform: [{ scale: rippleCircleScaleAnimation.value }], + })); + + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/animations/HourglassAnimation.tsx b/src/components/animations/HourglassAnimation.tsx new file mode 100644 index 00000000000..70cda52f21f --- /dev/null +++ b/src/components/animations/HourglassAnimation.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import Animated, { + Easing, + useAnimatedStyle, + useDerivedValue, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; +import { Path, Svg } from 'react-native-svg'; +import { + Box, + useColorMode, + useForegroundColor, +} from '@rainbow-me/design-system'; + +type AnimationConfigOptions = { + duration: number; + easing: Animated.EasingFunction; +}; + +const rotationConfig: AnimationConfigOptions = { + duration: 1200, + easing: Easing.elastic(1), +}; + +const sandConfig: AnimationConfigOptions = { + duration: 1200, + easing: Easing.bezierFn(1, 0.2, 0.47, 0.97), +}; + +const AnimatedPath = Animated.createAnimatedComponent(Path); + +export default function HourglassAnimation() { + const { colorMode } = useColorMode(); + const darkMode = colorMode !== 'light'; + const accentColor = useForegroundColor('accent'); + const rotateHourglass = useDerivedValue(() => + withRepeat( + withSequence( + withTiming(180, rotationConfig), + withTiming(0, rotationConfig), + withTiming(-180, rotationConfig) + ), + -1 + ) + ); + + const offsetSandMask = useDerivedValue(() => + withRepeat( + withSequence( + withTiming(0, sandConfig), + withTiming(34, sandConfig), + withTiming(0, sandConfig) + ), + -1 + ) + ); + + const animatedRotationStyles = useAnimatedStyle(() => ({ + transform: [ + { + rotateZ: `${rotateHourglass.value}deg`, + }, + ], + })); + + const animatedSandStyles = useAnimatedStyle(() => ({ + transform: [{ translateY: offsetSandMask.value }], + })); + + return ( + + + + + + + + + + + ); +} diff --git a/src/components/animations/index.js b/src/components/animations/index.js index 73e25948c19..a05901c5cc1 100644 --- a/src/components/animations/index.js +++ b/src/components/animations/index.js @@ -17,5 +17,7 @@ export { default as OpacityToggler } from './OpacityToggler'; export { default as ScaleInAnimation } from './ScaleInAnimation'; export { default as ShimmerAnimation } from './ShimmerAnimation'; export { default as SpinAnimation } from './SpinAnimation'; +export { default as HourglassAnimation } from './HourglassAnimation'; +export { default as CheckmarkAnimation } from './CheckmarkAnimation'; export const RoundButtonCapSize = 30; diff --git a/src/components/asset-list/RecyclerAssetList2/WrappedNFT.tsx b/src/components/asset-list/RecyclerAssetList2/WrappedNFT.tsx index fdac6f38ddc..4ae7ba42f3f 100644 --- a/src/components/asset-list/RecyclerAssetList2/WrappedNFT.tsx +++ b/src/components/asset-list/RecyclerAssetList2/WrappedNFT.tsx @@ -1,18 +1,36 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; +import { + // @ts-ignore + IS_TESTING, +} from 'react-native-dotenv'; import { UniqueTokenCard } from '../../unique-token'; import { Box, BoxProps } from '@rainbow-me/design-system'; +import { UniqueAsset } from '@rainbow-me/entities'; import { useCollectible } from '@rainbow-me/hooks'; import { useNavigation } from '@rainbow-me/navigation'; import Routes from '@rainbow-me/routes'; export default React.memo(function WrappedNFT({ + onPress, uniqueId, placement, }: { + onPress?: (asset: UniqueAsset) => void; uniqueId: string; placement: 'left' | 'right'; }) { - const asset = useCollectible({ uniqueId }); + const assetCollectible = useCollectible({ uniqueId }); + + const asset = useMemo( + () => ({ + ...assetCollectible, + ...(IS_TESTING === 'true' + ? { image_original_url: null, image_preview_url: null, image_url: null } + : {}), + }), + [assetCollectible] + ); + const { navigate } = useNavigation(); const handleItemPress = useCallback( @@ -21,13 +39,13 @@ export default React.memo(function WrappedNFT({ asset, backgroundOpacity: 1, cornerRadius: 'device', - external: false, + external: assetCollectible?.isExternal || false, springDamping: 1, topOffset: 0, transitionDuration: 0.25, type: 'unique_token', }), - [navigate] + [assetCollectible?.isExternal, navigate] ); const placementProps: BoxProps = @@ -40,10 +58,14 @@ export default React.memo(function WrappedNFT({ alignItems: 'flex-end', paddingRight: '19px', }; - return ( - - + + ); }); diff --git a/src/components/asset-list/RecyclerAssetList2/core/Contexts.ts b/src/components/asset-list/RecyclerAssetList2/core/Contexts.ts index 15a0dbc25f8..ea0a9bfa26f 100644 --- a/src/components/asset-list/RecyclerAssetList2/core/Contexts.ts +++ b/src/components/asset-list/RecyclerAssetList2/core/Contexts.ts @@ -3,18 +3,26 @@ import { Animated as RNAnimated } from 'react-native'; import { useDeepCompareMemo } from 'use-deep-compare'; import { CellTypes } from './ViewTypes'; +import { UniqueAsset } from '@rainbow-me/entities'; -export const RecyclerAssetListContext = React.createContext< - Record ->({}); +export const RecyclerAssetListContext = React.createContext<{ + additionalData: Record; + externalAddress?: string; + onPressUniqueToken?: (asset: UniqueAsset) => void; +}>({ additionalData: {}, onPressUniqueToken: undefined }); export const RecyclerAssetListScrollPositionContext = React.createContext< RNAnimated.Value | undefined >(undefined); export function useAdditionalRecyclerAssetListData(uid: string) { - const context = useContext(RecyclerAssetListContext)[uid]; - return useDeepCompareMemo(() => context, [context]); + const { additionalData, externalAddress, onPressUniqueToken } = useContext( + RecyclerAssetListContext + ); + return useDeepCompareMemo( + () => ({ ...additionalData[uid], externalAddress, onPressUniqueToken }), + [additionalData[uid], externalAddress, onPressUniqueToken] + ); } export function useRecyclerAssetListPosition() { diff --git a/src/components/asset-list/RecyclerAssetList2/core/ExternalENSProfileScrollView.tsx b/src/components/asset-list/RecyclerAssetList2/core/ExternalENSProfileScrollView.tsx new file mode 100644 index 00000000000..858db41f056 --- /dev/null +++ b/src/components/asset-list/RecyclerAssetList2/core/ExternalENSProfileScrollView.tsx @@ -0,0 +1,93 @@ +import { BottomSheetScrollView } from '@gorhom/bottom-sheet'; +import { BottomSheetContext } from '@gorhom/bottom-sheet/src/contexts/external'; +import React, { + RefObject, + useCallback, + useContext, + useEffect, + useImperativeHandle, + useState, +} from 'react'; +import { ScrollViewProps, ViewStyle } from 'react-native'; +import Animated, { runOnUI, useSharedValue } from 'react-native-reanimated'; + +import BaseScrollView, { + ScrollViewDefaultProps, +} from 'recyclerlistview/dist/reactnative/core/scrollcomponent/BaseScrollView'; +import { ProfileSheetConfigContext } from '../../../../screens/ProfileSheet'; +import ProfileSheetHeader from '../../../ens-profile/ProfileSheetHeader'; +import ImagePreviewOverlay from '../../../images/ImagePreviewOverlay'; +import { StickyHeaderContext } from './StickyHeaders'; + +const extraPadding = { paddingBottom: 144 }; +const ExternalENSProfileScrollViewWithRef = React.forwardRef< + BaseScrollView, + ScrollViewDefaultProps & { + children: React.ReactNode; + contentContainerStyle: ViewStyle; + } +>(function ExternalScrollView( + props: ScrollViewDefaultProps & { + children: React.ReactNode; + contentContainerStyle: ViewStyle; + }, + ref +) { + const isInsideBottomSheet = !!useContext(BottomSheetContext); + const { enableZoomableImages } = useContext(ProfileSheetConfigContext); + + const { onScroll, ...rest } = props; + const { scrollViewRef } = useContext(StickyHeaderContext)!; + + const [scrollEnabled, setScrollEnabled] = useState(ios); + useEffect(() => { + // For Android, delay scroll until sheet has been mounted (to avoid + // ImagePreviewOverlay mounting issues). + if (android) { + setTimeout(() => setScrollEnabled(true), 500); + } + }); + + const yPosition = useSharedValue(0); + + const scrollHandler = useCallback( + y => { + 'worklet'; + yPosition.value = y; + }, + [yPosition] + ); + + const handleScroll = useCallback( + event => { + onScroll(event); + runOnUI(scrollHandler)(event.nativeEvent.contentOffset.y); + }, + [onScroll, scrollHandler] + ); + + useImperativeHandle(ref, () => scrollViewRef.current!); + + const ScrollView = isInsideBottomSheet + ? BottomSheetScrollView + : Animated.ScrollView; + + return ( + } + scrollEnabled={scrollEnabled} + > + + + {props.children} + + + ); +}); +export default ExternalENSProfileScrollViewWithRef; diff --git a/src/components/asset-list/RecyclerAssetList2/core/RawRecyclerList.tsx b/src/components/asset-list/RecyclerAssetList2/core/RawRecyclerList.tsx index c151128cbbb..52fd87c79da 100644 --- a/src/components/asset-list/RecyclerAssetList2/core/RawRecyclerList.tsx +++ b/src/components/asset-list/RecyclerAssetList2/core/RawRecyclerList.tsx @@ -3,7 +3,9 @@ import { LayoutChangeEvent } from 'react-native'; import { DataProvider, RecyclerListView } from 'recyclerlistview'; import { useMemoOne } from 'use-memo-one'; import useAccountSettings from '../../../../hooks/useAccountSettings'; +import { AssetListType } from '..'; import { useRecyclerAssetListPosition } from './Contexts'; +import ExternalENSProfileScrollViewWithRef from './ExternalENSProfileScrollView'; import ExternalScrollViewWithRef from './ExternalScrollView'; import RefreshControl from './RefreshControl'; import rowRenderer from './RowRenderer'; @@ -18,8 +20,10 @@ const dataProvider = new DataProvider((r1, r2) => { const RawMemoRecyclerAssetList = React.memo(function RawRecyclerAssetList({ briefSectionsData, + type, }: { briefSectionsData: BaseCellType[]; + type?: AssetListType; }) { const currentDataProvider = useMemoOne( () => dataProvider.cloneWithRows(briefSectionsData), @@ -78,7 +82,11 @@ const RawMemoRecyclerAssetList = React.memo(function RawRecyclerAssetList({ diff --git a/src/components/asset-list/RecyclerAssetList2/core/ViewTypes.ts b/src/components/asset-list/RecyclerAssetList2/core/ViewTypes.ts index 87a2212e44b..2a4cc4f27c2 100644 --- a/src/components/asset-list/RecyclerAssetList2/core/ViewTypes.ts +++ b/src/components/asset-list/RecyclerAssetList2/core/ViewTypes.ts @@ -1,5 +1,6 @@ import { RecyclerListView, RecyclerListViewProps } from 'recyclerlistview'; import { RecyclerListViewState } from 'recyclerlistview/dist/reactnative/core/RecyclerListView'; +import { UniqueAsset } from '@rainbow-me/entities'; export enum CellType { ASSETS_HEADER = 'ASSETS_HEADER', @@ -53,6 +54,7 @@ export type NFTExtraData = { type: CellType.NFT; index: number; uniqueId: string; + onPressUniqueToken?: (asset: UniqueAsset) => void; }; export type NFTFamilyExtraData = { type: CellType.FAMILY_HEADER; diff --git a/src/components/asset-list/RecyclerAssetList2/core/useMemoBriefSectionData.ts b/src/components/asset-list/RecyclerAssetList2/core/useMemoBriefSectionData.ts index 4a5829396ce..e586ff5cd61 100644 --- a/src/components/asset-list/RecyclerAssetList2/core/useMemoBriefSectionData.ts +++ b/src/components/asset-list/RecyclerAssetList2/core/useMemoBriefSectionData.ts @@ -1,8 +1,10 @@ import { useDeepCompareMemo } from 'use-deep-compare'; +import { AssetListType } from '..'; import { CellType, CoinExtraData, NFTFamilyExtraData } from './ViewTypes'; import { useCoinListEdited, useCoinListEditOptions, + useExternalWalletSectionsData, useOpenFamilies, useOpenInvestmentCards, useOpenSavings, @@ -10,8 +12,29 @@ import { useWalletSectionsData, } from '@rainbow-me/hooks'; -export default function useMemoBriefSectionData() { - const { briefSectionsData } = useWalletSectionsData(); +const FILTER_TYPES = { + 'ens-profile': [ + CellType.NFT_SPACE_AFTER, + CellType.NFT, + CellType.FAMILY_HEADER, + ], + 'select-nft': [ + CellType.NFT_SPACE_AFTER, + CellType.NFT, + CellType.FAMILY_HEADER, + ], +} as { [key in AssetListType]: CellType[] }; + +export default function useMemoBriefSectionData({ + externalAddress, + type, +}: { externalAddress?: string; type?: AssetListType } = {}) { + const { briefSectionsData }: { briefSectionsData: any[] } = externalAddress + ? // `externalAddress` is a static prop, so hooks will always execute in order. + // eslint-disable-next-line react-hooks/rules-of-hooks + useExternalWalletSectionsData({ address: externalAddress }) + : // eslint-disable-next-line react-hooks/rules-of-hooks + useWalletSectionsData(); const { isSmallBalancesOpen, stagger } = useOpenSmallBalances(); const { isSavingsOpen } = useOpenSavings(); const { isInvestmentCardsOpen } = useOpenInvestmentCards(); @@ -27,8 +50,17 @@ export default function useMemoBriefSectionData() { let afterCoins = false; // load firstly 12, then the rest after 1 sec let numberOfSmallBalancesAllowed = stagger ? 12 : briefSectionsData.length; + const filterTypes = type ? FILTER_TYPES[type as AssetListType] : []; const briefSectionsDataFiltered = briefSectionsData .filter((data, arrIndex, arr) => { + if ( + filterTypes && + filterTypes.length !== 0 && + !filterTypes.includes(data.type) + ) { + return false; + } + if ( arr[arrIndex - 1]?.type === CellType.COIN && data.type !== CellType.COIN_DIVIDER && @@ -109,7 +141,9 @@ export default function useMemoBriefSectionData() { index++; return true; }) - .map(({ uid, type }) => ({ type, uid })); + .map(({ uid, type: cellType }) => { + return { type: cellType, uid }; + }); return briefSectionsDataFiltered; }, [ briefSectionsData, diff --git a/src/components/asset-list/RecyclerAssetList2/index.tsx b/src/components/asset-list/RecyclerAssetList2/index.tsx index b582976f48f..545b2e8700f 100644 --- a/src/components/asset-list/RecyclerAssetList2/index.tsx +++ b/src/components/asset-list/RecyclerAssetList2/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { Animated as RNAnimated } from 'react-native'; import { useMemoOne } from 'use-memo-one'; import { @@ -9,19 +9,36 @@ import RawMemoRecyclerAssetList from './core/RawRecyclerList'; import { StickyHeaderManager } from './core/StickyHeaders'; import useMemoBriefSectionData from './core/useMemoBriefSectionData'; -function RecyclerAssetList() { +export type AssetListType = 'wallet' | 'ens-profile' | 'select-nft'; + +function RecyclerAssetList({ + externalAddress, + type = 'wallet', +}: { + /** An "external address" is an address that is not the current account address. */ + externalAddress?: string; + type?: AssetListType; +}) { const { memoizedResult: briefSectionsData, additionalData, - } = useMemoBriefSectionData(); + } = useMemoBriefSectionData({ externalAddress, type }); const position = useMemoOne(() => new RNAnimated.Value(0), []); + const value = useMemo(() => ({ additionalData, externalAddress }), [ + additionalData, + externalAddress, + ]); + return ( - + - + diff --git a/src/components/buttons/Button/Button.ios.js b/src/components/buttons/Button/Button.ios.js index d77cee38dac..5e85ace44b8 100644 --- a/src/components/buttons/Button/Button.ios.js +++ b/src/components/buttons/Button/Button.ios.js @@ -70,6 +70,7 @@ export default function Button({ style, textProps, type = ButtonShapeTypes.pill, + testID, ...props }) { const borderRadius = type === 'rounded' ? 14 : 50; @@ -80,6 +81,7 @@ export default function Button({ {...pick(props, Object.keys(ButtonPressAnimation.propTypes))} disabled={disabled} onPress={onPress} + testID={testID} > { - const { colors } = useTheme(); + const secondary06 = useForegroundColor('secondary06'); const height = 56; @@ -26,21 +34,25 @@ const TintButton = ({ height={`${height}px`} justifyContent="center" // @ts-expect-error - onPress={onPress} - style={useMemo(() => ({ overflow: 'hidden' as 'hidden' }), [])} + onPress={disabled ? () => undefined : onPress} + scale={disabled ? 1 : 0.8} + style={{ + opacity: disabled ? 0.5 : 1, + overflow: 'hidden', + }} + testID={testID} > - + + + {children} diff --git a/src/components/change-wallet/AddressRow.js b/src/components/change-wallet/AddressRow.js index dd56036dcc0..5b64f349e19 100644 --- a/src/components/change-wallet/AddressRow.js +++ b/src/components/change-wallet/AddressRow.js @@ -52,7 +52,8 @@ const sx = StyleSheet.create({ gradient: { alignSelf: 'center', borderRadius: 24, - height: 24, + height: 26, + justifyContent: 'center', marginLeft: 19, textAlign: 'center', }, @@ -80,12 +81,11 @@ const StyledBottomRowText = styled(BottomRowText)({ const ReadOnlyText = styled(Text).attrs({ align: 'center', - letterSpacing: 'roundedTight', - lineHeight: 'paragraphSmall', + letterSpacing: 'roundedMedium', + size: 'smedium', weight: 'semibold', })({ - paddingHorizontal: 6.5, - paddingVertical: 1, + paddingHorizontal: 8, }); const OptionsIcon = ({ onPress }) => { @@ -122,7 +122,7 @@ export default function AddressRow({ walletId, } = data; - const { colors } = useTheme(); + const { colors, isDarkMode } = useTheme(); let cleanedUpBalance = balance; if (balance === '0.00') { @@ -142,13 +142,13 @@ export default function AddressRow({ () => ({ ...gradientProps, colors: [ - colors.alpha(colors.gradients.lightGrey[0], 0.6), - colors.gradients.lightGrey[1], + colors.alpha(colors.blueGreyDark, 0.03), + colors.alpha(colors.blueGreyDark, isDarkMode ? 0.02 : 0.06), ], end: { x: 1, y: 1 }, start: { x: 0, y: 0 }, }), - [colors] + [colors, isDarkMode] ); return ( diff --git a/src/components/contacts/ContactRow.js b/src/components/contacts/ContactRow.js index 4edb0d85afd..fbfcea13653 100644 --- a/src/components/contacts/ContactRow.js +++ b/src/components/contacts/ContactRow.js @@ -1,4 +1,4 @@ -import React, { Fragment } from 'react'; +import React, { Fragment, useEffect, useMemo, useState } from 'react'; import { removeFirstEmojiFromString, returnStringFirstEmoji, @@ -10,13 +10,26 @@ import { Column, RowWithMargins } from '../layout'; import { TruncatedAddress, TruncatedENS, TruncatedText } from '../text'; import ContactAvatar from './ContactAvatar'; import ImageAvatar from './ImageAvatar'; +import useExperimentalFlag, { + PROFILES, +} from '@rainbow-me/config/experimentalHooks'; +import { fetchReverseRecord } from '@rainbow-me/handlers/ens'; +import { ENS_DOMAIN } from '@rainbow-me/helpers/ens'; import { isENSAddressFormat, isValidDomainFormat, } from '@rainbow-me/helpers/validators'; -import { useDimensions } from '@rainbow-me/hooks'; +import { + useContacts, + useDimensions, + useENSProfileImages, +} from '@rainbow-me/hooks'; import styled from '@rainbow-me/styled-components'; import { margin } from '@rainbow-me/styles'; +import { + addressHashedColorIndex, + addressHashedEmoji, +} from '@rainbow-me/utils/profileUtils'; const ContactAddress = styled(TruncatedAddress).attrs( ({ theme: { colors }, lite }) => ({ @@ -51,10 +64,18 @@ const ContactName = styled(TruncatedText).attrs(({ lite }) => ({ width: ({ deviceWidth }) => deviceWidth - 90, }); -const css = margin.object(6, 19, 13); +const css = { + default: margin.object(6, 19, 13), + symmetrical: margin.object(9.5, 19), +}; -const ContactRow = ({ address, color, nickname, ...props }, ref) => { +const ContactRow = ( + { address, color, nickname, symmetricalMargins, ...props }, + ref +) => { + const profilesEnabled = useExperimentalFlag(PROFILES); const { width: deviceWidth } = useDimensions(); + const { onAddOrUpdateContacts } = useContacts(); const { colors } = useTheme(); const { accountType, @@ -62,10 +83,12 @@ const ContactRow = ({ address, color, nickname, ...props }, ref) => { ens, image, label, + network, onPress, showcaseItem, testID, } = props; + let cleanedUpBalance = balance; if (balance === '0.00') { cleanedUpBalance = '0'; @@ -78,6 +101,49 @@ const ContactRow = ({ address, color, nickname, ...props }, ref) => { profileUtils.addressHashedEmoji(address) : null; + // if the accountType === 'suggestions', nickname will always be an ens or hex address, not a custom contact nickname + const initialENSName = + typeof ens === 'string' + ? ens + : nickname?.includes(ENS_DOMAIN) + ? nickname + : ''; + + const [ensName, setENSName] = useState(initialENSName); + + const { data: images } = useENSProfileImages(ensName, { + enabled: profilesEnabled && Boolean(ensName), + }); + + useEffect(() => { + if (profilesEnabled && accountType === 'contacts') { + const fetchENSName = async () => { + const name = await fetchReverseRecord(address); + if (name !== ensName) { + setENSName(name); + onAddOrUpdateContacts( + address, + name && isENSAddressFormat(nickname) ? name : nickname, + color, + network, + name + ); + } + }; + fetchENSName(); + } + }, [ + accountType, + onAddOrUpdateContacts, + address, + color, + ensName, + network, + nickname, + profilesEnabled, + setENSName, + ]); + let cleanedUpLabel = null; if (label) { cleanedUpLabel = removeFirstEmojiFromString(label); @@ -85,15 +151,34 @@ const ContactRow = ({ address, color, nickname, ...props }, ref) => { const handlePress = useCallback(() => { if (showcaseItem) { - onPress(showcaseItem); + onPress(showcaseItem, nickname); } else { - const label = + const recipient = accountType === 'suggestions' && isENSAddressFormat(nickname) ? nickname - : address; - onPress(label); + : ensName || address; + onPress(recipient, nickname ?? recipient); } - }, [accountType, address, nickname, onPress, showcaseItem]); + }, [accountType, address, ensName, nickname, onPress, showcaseItem]); + + const imageAvatar = profilesEnabled ? images?.avatarUrl : image; + + const emoji = useMemo(() => (address ? addressHashedEmoji(address) : ''), [ + address, + ]); + + const emojiAvatar = profilesEnabled + ? emoji + : avatar || nickname || label || ensName; + + const colorIndex = useMemo( + () => (address ? addressHashedColorIndex(address) : 0), + [address] + ); + + const bgColor = profilesEnabled + ? colors.avatarBackgrounds[colorIndex || 0] + : color; return ( { - {image ? ( - + {imageAvatar ? ( + ) : ( )} diff --git a/src/components/contacts/ImageAvatar.js b/src/components/contacts/ImageAvatar.js index 165d7322036..68ae4185682 100644 --- a/src/components/contacts/ImageAvatar.js +++ b/src/components/contacts/ImageAvatar.js @@ -10,7 +10,11 @@ const buildSmallShadows = (color, colors) => [ [0, 6, 10, colors.avatarBackgrounds[color] || color, 0.2], ]; -const sizeConfigs = colors => ({ +const sizeConfigs = (colors, isDarkMode) => ({ + header: { + dimensions: 34, + textSize: 'large', + }, large: { dimensions: 65, shadow: [ @@ -29,15 +33,13 @@ const sizeConfigs = colors => ({ }, medium: { dimensions: 40, - shadow: [ - [0, 4, 6, colors.shadow, 0.04], - [0, 1, 3, colors.shadow, 0.08], - ], + shadow: [[0, 4, 12, colors.shadow, isDarkMode ? 0.3 : 0.15]], textSize: 'larger', }, small: { - dimensions: 34, - textSize: 'large', + dimensions: 30, + shadow: [[0, 3, 9, colors.shadow, 0.1]], + textSize: 'lmedium', }, smaller: { dimensions: 20, @@ -51,15 +53,15 @@ const Avatar = styled(ImgixImage)(({ dimensions }) => ({ })); const ImageAvatar = ({ image, size = 'medium', ...props }) => { - const { colors } = useTheme(); - const { dimensions, shadow } = useMemo(() => sizeConfigs(colors)[size], [ - colors, - size, - ]); + const { colors, isDarkMode } = useTheme(); + const { dimensions, shadow } = useMemo( + () => sizeConfigs(colors, isDarkMode)[size], + [colors, isDarkMode, size] + ); const shadows = useMemo( () => - size === 'small' || size === 'smaller' + size === 'header' || size === 'smaller' ? buildSmallShadows(colors.shadow, colors) : shadow, [shadow, size, colors] diff --git a/src/components/contacts/SwipeableContactRow.js b/src/components/contacts/SwipeableContactRow.js index a2c96ec2474..f46dc4de417 100644 --- a/src/components/contacts/SwipeableContactRow.js +++ b/src/components/contacts/SwipeableContactRow.js @@ -55,6 +55,9 @@ const SwipeableContactRow = ( accountType, address, color, + ens, + image, + network, nickname, onPress, onSelectEdit, @@ -80,8 +83,8 @@ const SwipeableContactRow = ( const handleEditContact = useCallback(() => { swipeableRef.current?.close?.(); - onSelectEdit({ address, color, nickname }); - }, [address, color, nickname, onSelectEdit]); + onSelectEdit({ address, color, ens, nickname }); + }, [address, color, ens, nickname, onSelectEdit]); const handleLongPress = useCallback( () => swipeableRef.current?.openRight?.(), @@ -124,6 +127,9 @@ const SwipeableContactRow = ( accountType={accountType} address={address} color={color} + ens={ens} + image={image} + network={network} nickname={nickname} onLongPress={handleLongPress} onPress={onPress} diff --git a/src/components/contacts/showDeleteContactActionSheet.js b/src/components/contacts/showDeleteContactActionSheet.js index 2fed2c91644..ee2e064ec96 100644 --- a/src/components/contacts/showDeleteContactActionSheet.js +++ b/src/components/contacts/showDeleteContactActionSheet.js @@ -6,7 +6,7 @@ import { showActionSheetWithOptions } from '@rainbow-me/utils'; const showDeleteContactActionSheet = ({ address, nickname, - onDelete, + onDelete = () => undefined, removeContact, }) => showActionSheetWithOptions( @@ -25,7 +25,7 @@ const showDeleteContactActionSheet = ({ if (buttonIndex === 0) { removeContact(address); ReactNativeHapticFeedback.trigger('notificationSuccess'); - onDelete(); + onDelete?.(); } } ); diff --git a/src/components/discover-sheet/DiscoverHome.js b/src/components/discover-sheet/DiscoverHome.js index a94a966a129..00f39d08cd9 100644 --- a/src/components/discover-sheet/DiscoverHome.js +++ b/src/components/discover-sheet/DiscoverHome.js @@ -1,7 +1,9 @@ import React from 'react'; +import useExperimentalFlag, { PROFILES } from '../../config/experimentalHooks'; import BottomSpacer from './BottomSpacer'; import Lists from './ListsSection'; import PulseIndex from './PulseIndexSection'; +import RegisterENS from './RegisterENSSection'; // import Strategies from './StrategiesSection'; import TopMoversSection from './TopMoversSection'; import UniswapPools from './UniswapPoolsSection'; @@ -9,9 +11,11 @@ import { useAccountSettings } from '@rainbow-me/hooks'; export default function DiscoverHome() { const { accountAddress } = useAccountSettings(); + const profilesEnabled = useExperimentalFlag(PROFILES); return ( + {profilesEnabled && } {/* */} diff --git a/src/components/discover-sheet/DiscoverSearch.js b/src/components/discover-sheet/DiscoverSearch.js index d4e81955da0..42e6c4025a5 100644 --- a/src/components/discover-sheet/DiscoverSearch.js +++ b/src/components/discover-sheet/DiscoverSearch.js @@ -1,3 +1,4 @@ +import analytics from '@segment/analytics-react-native'; import lang from 'i18n-js'; import React, { useCallback, @@ -16,6 +17,7 @@ import { CurrencySelectionList } from '../exchange'; import { initialChartExpandedStateSheetHeight } from '../expanded-state/asset/ChartExpandedState'; import { Row } from '../layout'; import DiscoverSheetContext from './DiscoverSheetContext'; +import { PROFILES, useExperimentalFlag } from '@rainbow-me/config'; import { fetchSuggestions } from '@rainbow-me/handlers/ens'; import { useHardwareBackOnFocus, @@ -44,6 +46,7 @@ export default function DiscoverSearch() { searchInputRef, cancelSearch, } = useContext(DiscoverSheetContext); + const profilesEnabled = useExperimentalFlag(PROFILES); const currencySelectionListRef = useRef(); const [searchQueryForSearch, setSearchQueryForSearch] = useState(''); @@ -53,11 +56,19 @@ export default function DiscoverSearch() { uniswapCurrencyList, uniswapCurrencyListLoading, } = useUniswapCurrencyList(searchQueryForSearch); + + const { colors } = useTheme(); + const currencyList = useMemo(() => [...uniswapCurrencyList, ...ensResults], [ uniswapCurrencyList, ensResults, ]); + const currencyListDataLength = + uniswapCurrencyList?.[0]?.data?.length || + 0 + ensResults?.[0]?.data?.length || + 0; + useHardwareBackOnFocus(() => { cancelSearch(); // prevent other back handlers from firing @@ -70,10 +81,21 @@ export default function DiscoverSearch() { // navigate to Showcase sheet searchInputRef?.current?.blur(); InteractionManager.runAfterInteractions(() => { - navigate(Routes.SHOWCASE_SHEET, { - address: item.nickname, - setIsSearchModeEnabled, - }); + navigate( + profilesEnabled ? Routes.PROFILE_SHEET : Routes.SHOWCASE_SHEET, + { + address: item.nickname, + fromRoute: 'DiscoverSearch', + setIsSearchModeEnabled, + } + ); + if (profilesEnabled) { + analytics.track('Viewed ENS profile', { + category: 'profiles', + ens: item.nickname, + from: 'Discover search', + }); + } }); } else { const asset = ethereumUtils.getAccountAsset(item.uniqueId); @@ -85,7 +107,13 @@ export default function DiscoverSearch() { }); } }, - [dispatch, navigate, searchInputRef, setIsSearchModeEnabled] + [ + dispatch, + navigate, + profilesEnabled, + searchInputRef, + setIsSearchModeEnabled, + ] ); const handleActionAsset = useCallback( @@ -105,20 +133,23 @@ export default function DiscoverSearch() { [handleActionAsset, handlePress] ); - const addEnsResults = useCallback(ensResults => { - let ensSearchResults = []; - if (ensResults && ensResults.length) { - ensSearchResults = [ - { - color: '#5893ff', - data: ensResults, - key: `􀏼 ${lang.t('discover.search.ethereum_name_service')}`, - title: `􀏼 ${lang.t('discover.search.ethereum_name_service')}`, - }, - ]; - } - setEnsResults(ensSearchResults); - }, []); + const addEnsResults = useCallback( + ensResults => { + let ensSearchResults = []; + if (ensResults && ensResults.length) { + ensSearchResults = [ + { + color: colors.appleBlue, + data: ensResults, + key: `􀉮 ${lang.t('discover.search.profiles')}`, + title: `􀉮 ${lang.t('discover.search.profiles')}`, + }, + ]; + } + setEnsResults(ensSearchResults); + }, + [colors.appleBlue] + ); useEffect(() => { const searching = searchQuery !== ''; @@ -130,7 +161,12 @@ export default function DiscoverSearch() { () => { setIsSearching(true); setSearchQueryForSearch(searchQuery); - fetchSuggestions(searchQuery, addEnsResults, setIsFetchingEns); + fetchSuggestions( + searchQuery, + addEnsResults, + setIsFetchingEns, + profilesEnabled + ); }, searchQuery === '' ? 1 : 500 ); @@ -154,7 +190,10 @@ export default function DiscoverSearch() { }, [isSearchModeEnabled]); return ( - + { scrollsToTopOnTapStatusBar={isFocused} snapPoints={snapPoints} style={{ - borderRadius: 20, + borderRadius: 30, overflow: 'hidden', }} > diff --git a/src/components/discover-sheet/DiscoverSheet.ios.js b/src/components/discover-sheet/DiscoverSheet.ios.js index 7bc862b8b0a..2b06de91e5e 100644 --- a/src/components/discover-sheet/DiscoverSheet.ios.js +++ b/src/components/discover-sheet/DiscoverSheet.ios.js @@ -109,6 +109,7 @@ function DiscoverSheet(_, forwardedRef) { renderHeader={renderHeader} scrollEnabled={!headerButtonsHandlers.isSearchModeEnabled} showBlur + testID="discover-sheet" > diff --git a/src/components/discover-sheet/DiscoverSheetContent.js b/src/components/discover-sheet/DiscoverSheetContent.js index ced2a618cac..fa379679b36 100644 --- a/src/components/discover-sheet/DiscoverSheetContent.js +++ b/src/components/discover-sheet/DiscoverSheetContent.js @@ -10,12 +10,14 @@ import styled from '@rainbow-me/styled-components'; const HeaderTitle = styled(Text).attrs(({ theme: { colors } }) => ({ align: 'center', - color: colors.alpha(colors.blueGreyDark, 0.8), + color: colors.dark, letterSpacing: 'roundedMedium', lineHeight: 'loose', size: 'large', weight: 'heavy', -}))({}); +}))({ + marginTop: 2, +}); const Spacer = styled.View({ height: 16, diff --git a/src/components/discover-sheet/DiscoverSheetHeader.js b/src/components/discover-sheet/DiscoverSheetHeader.js index 9f915031a2c..831f3f1c50b 100644 --- a/src/components/discover-sheet/DiscoverSheetHeader.js +++ b/src/components/discover-sheet/DiscoverSheetHeader.js @@ -195,12 +195,7 @@ export default function DiscoverSheetHeader(props) { onPress={() => !isSearchModeEnabled && navigate(Routes.WALLET_SCREEN)} translateX={android ? 4 : 5} > - + - + diff --git a/src/components/discover-sheet/PulseIndexSection.js b/src/components/discover-sheet/PulseIndexSection.js index 9b7873dd1c0..27611faabcb 100644 --- a/src/components/discover-sheet/PulseIndexSection.js +++ b/src/components/discover-sheet/PulseIndexSection.js @@ -167,7 +167,7 @@ const PulseIndex = () => { color={colors.dpiLight} numberOfLines={1} size="smedium" - weight="semibold" + weight="bold" > Trading at{' '} { align="right" color={item.isPositive ? colors.green : colors.red} size="smedium" - weight="semibold" + weight="bold" > {' '} {lang.t('discover.pulse.today_suffix')} diff --git a/src/components/discover-sheet/RegisterENSSection.tsx b/src/components/discover-sheet/RegisterENSSection.tsx new file mode 100644 index 00000000000..a5699ce93bf --- /dev/null +++ b/src/components/discover-sheet/RegisterENSSection.tsx @@ -0,0 +1,117 @@ +import lang from 'i18n-js'; +import React, { useCallback, useEffect } from 'react'; +import LinearGradient from 'react-native-linear-gradient'; +import { useNavigation } from '../../navigation/Navigation'; +import { ButtonPressAnimation } from '../animations'; +import { ensAvatarUrl } from '../ens-registration/IntroMarquee/IntroMarquee'; +import ENSIcon from '../icons/svg/ENSIcon'; +import ImgixImage from '../images/ImgixImage'; +import { enableActionsOnReadOnlyWallet } from '@rainbow-me/config'; +import { useTheme } from '@rainbow-me/context'; +import { + AccentColorProvider, + Box, + ColorModeProvider, + Inline, + Inset, + Stack, + Text, + useForegroundColor, +} from '@rainbow-me/design-system'; +import { useWallets } from '@rainbow-me/hooks'; +import { ensIntroMarqueeNames } from '@rainbow-me/references'; +import Routes from '@rainbow-me/routes'; +import { watchingAlert } from '@rainbow-me/utils'; + +export default function RegisterENSSection() { + const { navigate } = useNavigation(); + const { colors } = useTheme(); + const { isReadOnlyWallet } = useWallets(); + + const handlePress = useCallback(() => { + if (!isReadOnlyWallet || enableActionsOnReadOnlyWallet) { + navigate(Routes.REGISTER_ENS_NAVIGATOR, { + fromDiscover: true, + }); + } else { + watchingAlert(); + } + }, [isReadOnlyWallet, navigate]); + + useEffect(() => { + // Preload intro screen preview marquee ENS images + ImgixImage.preload( + ensIntroMarqueeNames.map(name => ({ uri: ensAvatarUrl(name) })) + ); + }, []); + + const shadow = useForegroundColor('shadow'); + const shadowColor = useForegroundColor({ + custom: { + dark: shadow, + light: colors.gradients.ens[1], + }, + }); + + return ( + + + + + + + + + + + + + {lang.t('profiles.banner.register_name')} + + + {lang.t('profiles.banner.and_create_ens_profile')} + + + + + + + 􀯼 + + + + + + + + ); +} diff --git a/src/components/discover-sheet/TopMoversSection.js b/src/components/discover-sheet/TopMoversSection.js index e2f2049f5af..a2f0417d360 100644 --- a/src/components/discover-sheet/TopMoversSection.js +++ b/src/components/discover-sheet/TopMoversSection.js @@ -1,6 +1,7 @@ import lang from 'i18n-js'; import React, { useCallback, useMemo } from 'react'; import { IS_TESTING } from 'react-native-dotenv'; +import { TopMoverCoinRow } from '../coin-row'; import { initialChartExpandedStateSheetHeight } from '../expanded-state/asset/ChartExpandedState'; import { Centered, Column, Flex } from '../layout'; import { MarqueeList } from '../list'; @@ -74,8 +75,21 @@ export default function TopMoversSection() { losers, ]); + const renderItem = useCallback( + ({ item, index, onPressCancel, onPressStart, testID }) => ( + + ), + [] + ); + return ( - + {(gainerItems?.length > 0 || loserItems?.length > 0) && ( @@ -93,6 +107,7 @@ export default function TopMoversSection() { {gainerItems?.length !== 0 && ( @@ -100,6 +115,7 @@ export default function TopMoversSection() { {loserItems?.length !== 0 && ( diff --git a/src/components/discover-sheet/UniswapPoolsSection.js b/src/components/discover-sheet/UniswapPoolsSection.js index 9a292022d8f..d6b2524c026 100644 --- a/src/components/discover-sheet/UniswapPoolsSection.js +++ b/src/components/discover-sheet/UniswapPoolsSection.js @@ -253,7 +253,7 @@ export default function UniswapPools({ } return ( - + 🐋 diff --git a/src/components/ens-profile/ActionButtons/ActionButton.tsx b/src/components/ens-profile/ActionButtons/ActionButton.tsx new file mode 100644 index 00000000000..93867f8fc0e --- /dev/null +++ b/src/components/ens-profile/ActionButtons/ActionButton.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { ButtonProps } from 'react-native'; +import { BackgroundColor } from '../../../design-system/color/palettes'; +import ButtonPressAnimation from '../../animations/ButtonPressAnimation'; +import { + AccentColorProvider, + Box, + Inset, + Space, + Text, + useForegroundColor, +} from '@rainbow-me/design-system'; + +type ActionButtonProps = { + children?: string; + color?: BackgroundColor | 'accent'; + icon?: string | React.ReactElement; + onPress?: ButtonProps['onPress']; + paddingHorizontal?: Space; + variant?: 'solid' | 'outlined'; + testID?: string; +}; + +export default function ActionButton({ + children, + icon, + onPress, + paddingHorizontal = '12px', + variant = 'solid', + testID, +}: ActionButtonProps) { + const appleBlue = useForegroundColor('action'); + const divider100 = useForegroundColor('divider100'); + const shadow = useForegroundColor('shadow'); + + const shadowColor = useForegroundColor({ + custom: { + dark: shadow, + light: appleBlue, + }, + }); + + const isIconOnly = Boolean(icon && !children); + return ( + + + + + {typeof icon !== 'string' && icon} + {(typeof icon === 'string' || children) && ( + + {icon || children} + + )} + + + + + ); +} diff --git a/src/components/ens-profile/ActionButtons/ActionButtons.tsx b/src/components/ens-profile/ActionButtons/ActionButtons.tsx new file mode 100644 index 00000000000..016e78f8053 --- /dev/null +++ b/src/components/ens-profile/ActionButtons/ActionButtons.tsx @@ -0,0 +1,45 @@ +import React, { useMemo } from 'react'; +import EditButton from './EditButton'; +import MoreButton from './MoreButton'; +import SendButton from './SendButton'; +import WatchButton from './WatchButton'; +import { Inline } from '@rainbow-me/design-system'; +import { useWallets } from '@rainbow-me/hooks'; + +export default function ActionButtons({ + address: primaryAddress, + ensName, + avatarUrl, +}: { + address?: string; + ensName?: string; + avatarUrl?: string | null; +}) { + const { wallets, isReadOnlyWallet } = useWallets(); + + const isOwner = useMemo(() => { + return Object.values(wallets || {}).some( + (wallet: any) => + wallet.type !== 'readOnly' && + wallet.addresses.some(({ address }: any) => address === primaryAddress) + ); + }, [primaryAddress, wallets]); + + return ( + + + {isOwner ? ( + + ) : ( + <> + + {!isReadOnlyWallet && } + + )} + + ); +} diff --git a/src/components/ens-profile/ActionButtons/EditButton.tsx b/src/components/ens-profile/ActionButtons/EditButton.tsx new file mode 100644 index 00000000000..a524c9e432b --- /dev/null +++ b/src/components/ens-profile/ActionButtons/EditButton.tsx @@ -0,0 +1,28 @@ +import { useNavigation } from '@react-navigation/core'; +import lang from 'i18n-js'; +import React, { useCallback } from 'react'; +import ActionButton from './ActionButton'; +import { REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; +import { useENSRegistration } from '@rainbow-me/hooks'; +import Routes from '@rainbow-me/routes'; + +export default function WatchButton({ ensName }: { ensName?: string }) { + const { navigate } = useNavigation(); + const { startRegistration } = useENSRegistration(); + + const handlePressEdit = useCallback(() => { + if (ensName) { + startRegistration(ensName, REGISTRATION_MODES.EDIT); + navigate(Routes.REGISTER_ENS_NAVIGATOR, { + ensName, + mode: REGISTRATION_MODES.EDIT, + }); + } + }, [ensName, navigate, startRegistration]); + + return ( + + {lang.t(`profiles.actions.edit_profile`)} + + ); +} diff --git a/src/components/ens-profile/ActionButtons/MoreButton.tsx b/src/components/ens-profile/ActionButtons/MoreButton.tsx new file mode 100644 index 00000000000..dd6f2829ebe --- /dev/null +++ b/src/components/ens-profile/ActionButtons/MoreButton.tsx @@ -0,0 +1,140 @@ +import lang from 'i18n-js'; +import React, { useCallback, useMemo } from 'react'; +import { Keyboard } from 'react-native'; +import { + ContextMenuButton, + MenuActionConfig, +} from 'react-native-ios-context-menu'; +import { showDeleteContactActionSheet } from '../../contacts'; +import More from '../MoreButton/MoreButton'; +import { useClipboard, useContacts } from '@rainbow-me/hooks'; +import { useNavigation } from '@rainbow-me/navigation'; +import Routes from '@rainbow-me/routes'; +import { ethereumUtils, showActionSheetWithOptions } from '@rainbow-me/utils'; +import { formatAddressForDisplay } from '@rainbow-me/utils/abbreviations'; + +const ACTIONS = { + ADD_CONTACT: 'add-contact', + COPY_ADDRESS: 'copy-address', + ETHERSCAN: 'etherscan', + REMOVE_CONTACT: 'remove-contact', +}; + +export default function MoreButton({ + address, + ensName, +}: { + address?: string; + ensName?: string; +}) { + const { navigate } = useNavigation(); + const { setClipboard } = useClipboard(); + const { contacts, onRemoveContact } = useContacts(); + + const contact = useMemo( + () => (address ? contacts[address.toLowerCase()] : undefined), + [address, contacts] + ); + + const formattedAddress = useMemo( + () => (address ? formatAddressForDisplay(address, 4, 4) : ''), + [address] + ); + + const menuItems = useMemo(() => { + return [ + { + actionKey: ACTIONS.COPY_ADDRESS, + actionTitle: lang.t('profiles.details.copy_address'), + discoverabilityTitle: formattedAddress, + icon: { + iconType: 'SYSTEM', + iconValue: 'square.on.square', + }, + }, + contact + ? { + actionKey: ACTIONS.REMOVE_CONTACT, + actionTitle: lang.t('profiles.details.remove_from_contacts'), + icon: { + iconType: 'SYSTEM', + iconValue: 'person.crop.circle.badge.minus', + }, + } + : { + actionKey: ACTIONS.ADD_CONTACT, + actionTitle: lang.t('profiles.details.add_to_contacts'), + icon: { + iconType: 'SYSTEM', + iconValue: 'person.crop.circle.badge.plus', + }, + }, + { + actionKey: ACTIONS.ETHERSCAN, + actionTitle: lang.t('profiles.details.view_on_etherscan'), + icon: { + iconType: 'SYSTEM', + iconValue: 'link', + }, + }, + ] as MenuActionConfig[]; + }, [contact, formattedAddress]); + + const handlePressMenuItem = useCallback( + ({ nativeEvent: { actionKey } }) => { + if (actionKey === ACTIONS.COPY_ADDRESS) { + setClipboard(address); + } + if (address && actionKey === ACTIONS.ETHERSCAN) { + ethereumUtils.openAddressInBlockExplorer(address); + } + if (actionKey === ACTIONS.ADD_CONTACT) { + navigate(Routes.MODAL_SCREEN, { + address, + contact, + ens: ensName, + nickname: ensName, + type: 'contact_profile', + }); + } + if (actionKey === ACTIONS.REMOVE_CONTACT) { + showDeleteContactActionSheet({ + address, + nickname: contact.nickname, + removeContact: onRemoveContact, + }); + android && Keyboard.dismiss(); + } + }, + [address, contact, ensName, navigate, onRemoveContact, setClipboard] + ); + + const handleAndroidPress = useCallback(() => { + const actionSheetOptions = menuItems + .map(item => item?.actionTitle) + .filter(Boolean) as any; + + showActionSheetWithOptions( + { + options: actionSheetOptions, + }, + async (buttonIndex: number) => { + const actionKey = menuItems[buttonIndex]?.actionKey; + handlePressMenuItem({ nativeEvent: { actionKey } }); + } + ); + }, [handlePressMenuItem, menuItems]); + + return ( + + + + ); +} diff --git a/src/components/ens-profile/ActionButtons/SendButton.tsx b/src/components/ens-profile/ActionButtons/SendButton.tsx new file mode 100644 index 00000000000..2668da31d5e --- /dev/null +++ b/src/components/ens-profile/ActionButtons/SendButton.tsx @@ -0,0 +1,27 @@ +import React, { useCallback } from 'react'; +import ActionButton from './ActionButton'; +import isNativeStackAvailable from '@rainbow-me/helpers/isNativeStackAvailable'; +import { useNavigation } from '@rainbow-me/navigation'; +import Routes from '@rainbow-me/routes'; + +export default function SendButton({ ensName }: { ensName?: string }) { + const { navigate } = useNavigation(); + const handlePressSend = useCallback(async () => { + if (isNativeStackAvailable || android) { + navigate(Routes.SEND_FLOW, { + params: { + address: ensName, + fromProfile: true, + }, + screen: Routes.SEND_SHEET, + }); + } else { + navigate(Routes.SEND_FLOW, { + address: ensName, + fromProfile: true, + }); + } + }, [ensName, navigate]); + + return ; +} diff --git a/src/components/ens-profile/ActionButtons/WatchButton.tsx b/src/components/ens-profile/ActionButtons/WatchButton.tsx new file mode 100644 index 00000000000..0768fb51134 --- /dev/null +++ b/src/components/ens-profile/ActionButtons/WatchButton.tsx @@ -0,0 +1,78 @@ +import ConditionalWrap from 'conditional-wrap'; +import lang from 'i18n-js'; +import React, { useCallback, useMemo, useState } from 'react'; +import { ContextMenuButton, MenuConfig } from 'react-native-ios-context-menu'; +import ActionButton from './ActionButton'; +import { useWatchWallet } from '@rainbow-me/hooks'; + +export default function WatchButton({ + address, + ensName, + avatarUrl, +}: { + address?: string; + ensName?: string; + avatarUrl?: string | null; +}) { + const { isImporting, isWatching, watchWallet } = useWatchWallet({ + address, + avatarUrl, + ensName, + showImportModal: false, + }); + + // An "optimistic" state will provide us with optimistic feedback on the UI, + // and not wait for the import to finish. + const [optimisticIsWatching, setOptimisticIsWatching] = useState(isWatching); + + const handlePressWatch = useCallback(() => { + if (!isImporting) { + watchWallet(); + setOptimisticIsWatching(isWatching => !isWatching); + } + }, [isImporting, watchWallet]); + + const menuConfig = useMemo(() => { + return { + menuItems: [ + { + actionKey: 'unwatch', + actionTitle: lang.t('profiles.actions.unwatch_ens', { ensName }), + menuAttributes: ['destructive'], + }, + ], + menuTitle: lang.t('profiles.actions.unwatch_ens_title', { ensName }), + } as MenuConfig; + }, [ensName]); + + return ( + ( + + {children} + + )} + > + null} + paddingHorizontal={isWatching ? { custom: 11.25 } : undefined} + testID="profile-sheet-watch-button" + variant={!optimisticIsWatching ? 'solid' : 'outlined'} + > + {(optimisticIsWatching ? '' : '􀨭 ') + + lang.t( + `profiles.actions.${optimisticIsWatching ? 'watching' : 'watch'}` + )} + + + ); +} diff --git a/src/components/ens-profile/MoreButton/MoreButton.tsx b/src/components/ens-profile/MoreButton/MoreButton.tsx new file mode 100644 index 00000000000..bc7351f08dc --- /dev/null +++ b/src/components/ens-profile/MoreButton/MoreButton.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import ThreeDotsIcon from '../../icons/svg/ThreeDotsIcon'; +import ActionButton from '../ActionButtons/ActionButton'; +import { useForegroundColor } from '@rainbow-me/design-system'; + +export default function MoreButton() { + const color = useForegroundColor('secondary80'); + return ( + } + variant="outlined" + /> + ); +} diff --git a/src/components/ens-profile/ProfileAvatar/ProfileAvatar.tsx b/src/components/ens-profile/ProfileAvatar/ProfileAvatar.tsx new file mode 100644 index 00000000000..9211e1e3329 --- /dev/null +++ b/src/components/ens-profile/ProfileAvatar/ProfileAvatar.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Text as NativeText } from 'react-native'; +import Animated from 'react-native-reanimated'; +import { ImagePreviewOverlayTarget } from '../../images/ImagePreviewOverlay'; +import Skeleton from '../../skeleton/Skeleton'; +import AvatarCoverPhotoMaskSvg from '../../svg/AvatarCoverPhotoMaskSvg'; +import { BackgroundProvider, Box, Cover } from '@rainbow-me/design-system'; +import { useFadeImage } from '@rainbow-me/hooks'; +import { ImgixImage } from '@rainbow-me/images'; +import { sharedCoolModalTopOffset } from '@rainbow-me/navigation/config'; + +const imagePreviewOverlayTopOffset = ios ? 68 + sharedCoolModalTopOffset : 107; +const size = 70; + +export default function ProfileAvatar({ + accountSymbol, + avatarUrl, + enableZoomOnPress, + handleOnPress, + isFetched, +}: { + accountSymbol?: string | null; + avatarUrl?: string | null; + enableZoomOnPress?: boolean; + handleOnPress?: () => void; + isFetched?: boolean; +}) { + const { isLoading, onLoadEnd, style } = useFadeImage({ + enabled: isFetched, + source: avatarUrl ? { uri: avatarUrl } : undefined, + }); + + const showAccentBackground = !avatarUrl && isFetched && !isLoading; + const showSkeleton = isLoading || !isFetched; + + return ( + + + + {({ backgroundColor }) => ( + + )} + + + + + <> + {showSkeleton && ( + + + + + + + + )} + + {avatarUrl ? ( + + ) : ( + + + + + {accountSymbol || ''} + + + + + )} + + + + + + ); +} diff --git a/src/components/ens-profile/ProfileCover/ProfileCover.tsx b/src/components/ens-profile/ProfileCover/ProfileCover.tsx new file mode 100644 index 00000000000..2fa27105021 --- /dev/null +++ b/src/components/ens-profile/ProfileCover/ProfileCover.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { View } from 'react-native'; +import RadialGradient from 'react-native-radial-gradient'; +import Animated from 'react-native-reanimated'; +import { ImagePreviewOverlayTarget } from '../../images/ImagePreviewOverlay'; +import Skeleton from '../../skeleton/Skeleton'; +import { Box, useForegroundColor } from '@rainbow-me/design-system'; +import { useFadeImage } from '@rainbow-me/hooks'; +import { ImgixImage } from '@rainbow-me/images'; +import { sharedCoolModalTopOffset } from '@rainbow-me/navigation/config'; + +const imagePreviewOverlayTopOffset = ios ? 68 + sharedCoolModalTopOffset : 107; + +export default function ProfileCover({ + coverUrl, + enableZoomOnPress, + handleOnPress, + isFetched, +}: { + coverUrl?: string | null; + enableZoomOnPress?: boolean; + handleOnPress?: () => void; + isFetched: boolean; +}) { + const accentColor = useForegroundColor('accent'); + + const { isLoading, onLoadEnd, style } = useFadeImage({ + enabled: isFetched, + source: coverUrl ? { uri: coverUrl } : undefined, + }); + + const showSkeleton = isLoading || !isFetched; + const showRadialGradient = ios && !coverUrl && isFetched && !isLoading; + + return ( + <> + {showSkeleton && ( + + + + + + )} + + + + + + + + + ); +} diff --git a/src/components/ens-profile/ProfileDescription/ProfileDescription.tsx b/src/components/ens-profile/ProfileDescription/ProfileDescription.tsx new file mode 100644 index 00000000000..ac3818a1015 --- /dev/null +++ b/src/components/ens-profile/ProfileDescription/ProfileDescription.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import RecordHyperlink from '../RecordHyperlink/RecordHyperlink'; +import { Inline, Text } from '@rainbow-me/design-system'; + +const LINK_REGEX = /[^\s]+\.(eth|com|net|xyz|org|co|us|me)/g; +const DIVIDER = 'ㅤㅤㅤㅤ'; + +const ProfileDescription = ({ description }: { description?: string }) => { + if (!description) return null; + const hyperlinks = description.match(LINK_REGEX); + const text = description.replace(LINK_REGEX, DIVIDER).split(DIVIDER); + + return ( + + {text?.map((t, i) => ( + <> + + {t} + + {hyperlinks?.[i] && } + + ))} + + ); +}; + +export default ProfileDescription; diff --git a/src/components/ens-profile/ProfileSheetHeader.tsx b/src/components/ens-profile/ProfileSheetHeader.tsx new file mode 100644 index 00000000000..72dbdb8cf42 --- /dev/null +++ b/src/components/ens-profile/ProfileSheetHeader.tsx @@ -0,0 +1,266 @@ +import { useRoute } from '@react-navigation/core'; +import React, { useCallback, useContext, useMemo } from 'react'; +import { ModalContext } from '../../react-native-cool-modals/NativeStackView'; +import { ProfileSheetConfigContext } from '../../screens/ProfileSheet'; +import Skeleton from '../skeleton/Skeleton'; +import ActionButtons from './ActionButtons/ActionButtons'; +import ProfileAvatar from './ProfileAvatar/ProfileAvatar'; +import ProfileCover from './ProfileCover/ProfileCover'; +import ProfileDescription from './ProfileDescription/ProfileDescription'; +import RecordTags, { + Placeholder as RecordTagsPlaceholder, +} from './RecordTags/RecordTags'; +import { + Bleed, + Box, + Column, + Columns, + Divider, + Heading, + Inset, + Stack, +} from '@rainbow-me/design-system'; +import { UniqueAsset } from '@rainbow-me/entities'; +import { ENS_RECORDS } from '@rainbow-me/helpers/ens'; +import { + useENSProfile, + useENSProfileImages, + useFetchUniqueTokens, + useFirstTransactionTimestamp, +} from '@rainbow-me/hooks'; +import { useNavigation } from '@rainbow-me/navigation'; +import Routes from '@rainbow-me/routes'; +import { isENSNFTRecord, parseENSNFTRecord } from '@rainbow-me/utils'; +import { addressHashedEmoji } from '@rainbow-me/utils/profileUtils'; + +export default function ProfileSheetHeader({ + ensName: defaultEnsName, + isLoading, + isPreview, +}: { + ensName?: string; + isLoading?: boolean; + isPreview?: boolean; +}) { + const { params } = useRoute(); + const { enableZoomableImages } = useContext(ProfileSheetConfigContext); + const { layout } = useContext(ModalContext) || {}; + + const ensName = defaultEnsName || params?.address; + const { data: profile } = useENSProfile(ensName); + const { data: images, isFetched: isImagesFetched } = useENSProfileImages( + ensName + ); + const profileAddress = profile?.primary?.address ?? ''; + const { navigate } = useNavigation(); + const { data: uniqueTokens } = useFetchUniqueTokens({ + address: profileAddress, + }); + + const handleSelectNFT = useCallback( + (uniqueToken: UniqueAsset) => { + navigate(Routes.EXPANDED_ASSET_SHEET, { + asset: uniqueToken, + backgroundOpacity: 1, + cornerRadius: 'device', + external: true, + springDamping: 1, + topOffset: 0, + transitionDuration: 0.25, + type: 'unique_token', + }); + }, + [navigate] + ); + + const getUniqueToken = useCallback( + (avatarOrCover: string) => { + const { contractAddress, tokenId } = parseENSNFTRecord(avatarOrCover); + const uniqueToken = uniqueTokens?.find( + token => + token.asset_contract.address === contractAddress && + token.id === tokenId + ); + return uniqueToken; + }, + [uniqueTokens] + ); + + const avatarUrl = images?.avatarUrl; + + const { enableZoomOnPressAvatar, onPressAvatar } = useMemo(() => { + const avatar = profile?.records?.avatar; + + const isNFTAvatar = avatar && isENSNFTRecord(avatar); + const avatarUniqueToken = isNFTAvatar && getUniqueToken(avatar); + + const onPressAvatar = avatarUniqueToken + ? () => handleSelectNFT(avatarUniqueToken) + : undefined; + + const enableZoomOnPressAvatar = enableZoomableImages && !onPressAvatar; + + return { + enableZoomOnPressAvatar, + onPressAvatar, + }; + }, [ + enableZoomableImages, + getUniqueToken, + handleSelectNFT, + profile?.records?.avatar, + ]); + + const coverUrl = images?.coverUrl; + + const { enableZoomOnPressCover, onPressCover } = useMemo(() => { + const cover = profile?.records?.cover; + + const isNFTCover = cover && isENSNFTRecord(cover); + const coverUniqueToken = isNFTCover && getUniqueToken(cover); + + const onPressCover = coverUniqueToken + ? () => handleSelectNFT(coverUniqueToken) + : undefined; + + const enableZoomOnPressCover = enableZoomableImages && !onPressCover; + + return { + coverUrl, + enableZoomOnPressCover, + onPressCover, + }; + }, [ + coverUrl, + enableZoomableImages, + getUniqueToken, + handleSelectNFT, + profile?.records?.cover, + ]); + + const { data: firstTransactionTimestamp } = useFirstTransactionTimestamp({ + ensName, + }); + + const emoji = useMemo( + () => (profileAddress ? addressHashedEmoji(profileAddress) : ''), + [profileAddress] + ); + + return ( + setTimeout(() => layout(e), 500) })} + > + + + + + + + + + {!isLoading && ( + + + + )} + + + + + + {ensName} + <> + {isLoading ? ( + + ) : profile?.records?.description ? ( + + ) : null} + + + {isLoading ? ( + + ) : ( + <> + {profile?.records && ( + + )} + + )} + + {!isPreview && ( + + + + )} + + + + + ); +} + +function DescriptionPlaceholder() { + return ( + + + + + + + + + ); +} diff --git a/src/components/ens-profile/RecordHyperlink/RecordHyperlink.tsx b/src/components/ens-profile/RecordHyperlink/RecordHyperlink.tsx new file mode 100644 index 00000000000..4f22f1ecbd7 --- /dev/null +++ b/src/components/ens-profile/RecordHyperlink/RecordHyperlink.tsx @@ -0,0 +1,32 @@ +import { useNavigation } from '@react-navigation/core'; +import React, { useCallback } from 'react'; +import { Linking } from 'react-native'; +import ButtonPressAnimation from '../../animations/ButtonPressAnimation'; +import { Text } from '@rainbow-me/design-system'; +import Routes from '@rainbow-me/routes'; + +const ENS_REGEX = /[^\s]+.eth/g; + +export default function RecordHyperlink({ value }: { value: string }) { + const { goBack, navigate } = useNavigation(); + + const navigateToProfile = useCallback(() => { + if (value.match(ENS_REGEX)) { + goBack(); + navigate(Routes.PROFILE_SHEET, { + address: value, + fromRoute: 'RecordHyperlink', + }); + } else { + Linking.openURL((value.match('https') ? '' : 'https://') + value); + } + }, [navigate, value, goBack]); + + return ( + + + {value} + + + ); +} diff --git a/src/components/ens-profile/RecordTags/RecordTags.tsx b/src/components/ens-profile/RecordTags/RecordTags.tsx new file mode 100644 index 00000000000..e6d57f6a51b --- /dev/null +++ b/src/components/ens-profile/RecordTags/RecordTags.tsx @@ -0,0 +1,201 @@ +import { format } from 'date-fns'; +import React, { useMemo } from 'react'; +import { ScrollView } from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; +import ButtonPressAnimation from '../../animations/ButtonPressAnimation'; +import { Icon } from '../../icons'; +import Skeleton from '../../skeleton/Skeleton'; +import { useTheme } from '@rainbow-me/context'; +import { + Bleed, + Box, + Inline, + Inset, + Text, + useForegroundColor, +} from '@rainbow-me/design-system'; +import { Records } from '@rainbow-me/entities'; +import { ENS_RECORDS } from '@rainbow-me/helpers/ens'; +import { useENSRecordDisplayProperties } from '@rainbow-me/hooks'; + +const getRecordType = (recordKey: string) => { + switch (recordKey) { + case ENS_RECORDS.BTC: + case ENS_RECORDS.LTC: + case ENS_RECORDS.DOGE: + case ENS_RECORDS.ETH: + return 'address'; + default: + return 'record'; + } +}; +export default function RecordTags({ + firstTransactionTimestamp, + records, + show, +}: { + firstTransactionTimestamp?: number; + records: Partial; + show: ENS_RECORDS[]; +}) { + const recordsToShow = useMemo( + () => + show.map(key => ({ + key, + type: getRecordType(key), + value: records[key], + })) as { + key: string; + value: string; + type: 'address' | 'record'; + }[], + [records, show] + ); + + return ( + + + + {recordsToShow?.map(({ key: recordKey, value: recordValue, type }) => + recordValue ? ( + + ) : null + )} + {firstTransactionTimestamp && ( + + Since {format(firstTransactionTimestamp, 'MMM yyyy')} + + )} + + + + ); +} + +function Tag({ + children, + color, + icon, + size = '14px', + symbol, +}: { + children: React.ReactNode; + color: 'appleBlue' | 'grey'; + icon?: string; + size?: '14px' | '16px'; + symbol?: string; +}) { + const { colors } = useTheme(); + + const gradients = { + appleBlue: colors.gradients.transparentToAppleBlue, + grey: colors.gradients.lightGreyTransparent, + }; + + const action = useForegroundColor('action'); + const secondary80 = useForegroundColor('secondary80'); + const iconColors = { + appleBlue: action, + grey: secondary80, + } as const; + + const textColors = { + appleBlue: 'action', + grey: 'secondary80', + } as const; + + return ( + + + + {icon && ( + + + + )} + + {symbol ? `${symbol} ` : ''} + {children} + + + + + ); +} + +function RecordTag({ + recordKey, + recordValue, + type, +}: { + recordKey: string; + recordValue: string; + type: 'address' | 'record'; +}) { + const { ContextMenuButton, icon, value } = useENSRecordDisplayProperties({ + key: recordKey, + type, + value: recordValue, + }); + return ( + + + + {value} + + + + ); +} + +export function Placeholder() { + return ( + + + + + + + + + + + + + ); +} + +export function PlaceholderItem() { + return ( + + ); +} diff --git a/src/components/ens-registration/ConfirmContent/CommitContent.tsx b/src/components/ens-registration/ConfirmContent/CommitContent.tsx new file mode 100644 index 00000000000..697c9204abf --- /dev/null +++ b/src/components/ens-registration/ConfirmContent/CommitContent.tsx @@ -0,0 +1,71 @@ +import lang from 'i18n-js'; +import React from 'react'; +import { Source } from 'react-native-fast-image'; +import brain from '../../../assets/brain.png'; +import { RegistrationReviewRows } from '../../../components/ens-registration'; +import { + Box, + Divider, + Inline, + Inset, + Stack, + Text, +} from '@rainbow-me/design-system'; +import { REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; +import { useDimensions } from '@rainbow-me/hooks'; +import { ImgixImage } from '@rainbow-me/images'; + +const CommitContent = ({ + duration, + registrationCostsData, + setDuration, +}: { + duration: number; + registrationCostsData: any; + setDuration: React.Dispatch>; +}) => { + const { isSmallPhone } = useDimensions(); + + return ( + + + + + + + + + {lang.t('profiles.confirm.suggestion')} + + + + + + + ); +}; + +export default CommitContent; diff --git a/src/components/ens-registration/ConfirmContent/RegisterContent.tsx b/src/components/ens-registration/ConfirmContent/RegisterContent.tsx new file mode 100644 index 00000000000..a4c395044cf --- /dev/null +++ b/src/components/ens-registration/ConfirmContent/RegisterContent.tsx @@ -0,0 +1,99 @@ +import lang from 'i18n-js'; +import React, { useCallback } from 'react'; +import { Switch } from 'react-native-gesture-handler'; +import StepIndicator from '../../../components/step-indicator/StepIndicator'; +import ButtonPressAnimation from '../../animations/ButtonPressAnimation'; +import { + Box, + Divider, + Inline, + Row, + Rows, + Stack, + Text, +} from '@rainbow-me/design-system'; +import { useNavigation } from '@rainbow-me/navigation'; +import Routes from '@rainbow-me/routes'; +import { colors } from '@rainbow-me/styles'; + +const RegisterContent = ({ + accentColor, + sendReverseRecord, + setSendReverseRecord, +}: { + accentColor: any; + sendReverseRecord: boolean; + setSendReverseRecord: React.Dispatch> | null; +}) => { + const { navigate } = useNavigation(); + const openPrimaryENSNameHelper = useCallback(() => { + navigate(Routes.EXPLAIN_SHEET, { type: 'ens_primary_name' }); + }, [navigate]); + + return ( + <> + + + + + + + + + {lang.t('profiles.confirm.last_step')} 💈 + + + {lang.t('profiles.confirm.last_step_description')} + + + + + + + + + + + {`${lang.t('profiles.confirm.set_ens_name')} `} + + + + 􀅵 + + + + + setSendReverseRecord?.( + sendReverseRecord => !sendReverseRecord + ) + } + testID="ens-reverse-record-switch" + thumbColor={colors.white} + trackColor={{ false: colors.white, true: accentColor }} + value={sendReverseRecord} + /> + + + + + + ); +}; + +export default RegisterContent; diff --git a/src/components/ens-registration/ConfirmContent/RenewContent.tsx b/src/components/ens-registration/ConfirmContent/RenewContent.tsx new file mode 100644 index 00000000000..2cdee1347c9 --- /dev/null +++ b/src/components/ens-registration/ConfirmContent/RenewContent.tsx @@ -0,0 +1,59 @@ +import { format } from 'date-fns'; +import React from 'react'; +import { RegistrationReviewRows } from '../../../components/ens-registration'; +import { Divider, Inset, Stack } from '@rainbow-me/design-system'; +import { ENS_DOMAIN, REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; +import { useDimensions, useENSProfile } from '@rainbow-me/hooks'; +import { timeUnits } from '@rainbow-me/references'; + +const RenewContent = ({ + yearsDuration, + registrationCostsData, + setDuration, + name, +}: { + yearsDuration: number; + registrationCostsData: any; + setDuration: React.Dispatch>; + name: string; +}) => { + const { isSmallPhone } = useDimensions(); + + const ensProfile = useENSProfile(name + ENS_DOMAIN, { enabled: true }); + const expiryDate = ensProfile?.data?.registration?.expiryDate || 0; + + const newExpiryDateFormatted = format( + new Date( + Number(expiryDate * 1000) + yearsDuration * timeUnits.secs.year * 1000 + ), + 'MMM d, yyyy' + ); + + return ( + + + + + + + + ); +}; + +export default RenewContent; diff --git a/src/components/ens-registration/ConfirmContent/WaitCommitmentConfirmationContent.tsx b/src/components/ens-registration/ConfirmContent/WaitCommitmentConfirmationContent.tsx new file mode 100644 index 00000000000..696f68905a8 --- /dev/null +++ b/src/components/ens-registration/ConfirmContent/WaitCommitmentConfirmationContent.tsx @@ -0,0 +1,69 @@ +import lang from 'i18n-js'; +import React from 'react'; +import { + ButtonPressAnimation, + HourglassAnimation, +} from '../../../components/animations'; +import StepIndicator from '../../../components/step-indicator/StepIndicator'; +import { + Box, + Heading, + Inset, + Row, + Rows, + Stack, + Text, +} from '@rainbow-me/design-system'; +import { useDimensions } from '@rainbow-me/hooks'; + +const WaitCommitmentConfirmationContent = ({ + accentColor, + action, +}: { + accentColor: any; + action: () => void; +}) => { + const { isSmallPhone } = useDimensions(); + + return ( + <> + + + + + + + + + + + + {lang.t('profiles.confirm.transaction_pending')} + + + {lang.t('profiles.confirm.transaction_pending_description')} + + + + + + + + + + + {`🚀 ${lang.t('profiles.confirm.speed_up')}`} + + + + + ); +}; + +export default WaitCommitmentConfirmationContent; diff --git a/src/components/ens-registration/ConfirmContent/WaitENSConfirmationContent.tsx b/src/components/ens-registration/ConfirmContent/WaitENSConfirmationContent.tsx new file mode 100644 index 00000000000..08e10d860ae --- /dev/null +++ b/src/components/ens-registration/ConfirmContent/WaitENSConfirmationContent.tsx @@ -0,0 +1,56 @@ +import lang from 'i18n-js'; +import React from 'react'; +import LargeCountdownClock from '../../../components/large-countdown-clock/LargeCountdownClock'; +import StepIndicator from '../../../components/step-indicator/StepIndicator'; +import { + Box, + Heading, + Inset, + Row, + Rows, + Stack, + Text, +} from '@rainbow-me/design-system'; +import { ENS_SECONDS_WAIT } from '@rainbow-me/helpers/ens'; +import { useDimensions } from '@rainbow-me/hooks'; + +const WaitENSConfirmationContent = ({ + seconds, +}: { + seconds: number | undefined; +}) => { + const { isSmallPhone } = useDimensions(); + + return ( + <> + + + + + + + + + {}} + seconds={seconds || ENS_SECONDS_WAIT} + /> + + + {lang.t('profiles.confirm.wait_one_minute')} + + + {lang.t('profiles.confirm.wait_one_minute_description')} + + + + + + + + + ); +}; + +export default WaitENSConfirmationContent; diff --git a/src/components/ens-registration/IntroMarquee/IntroMarquee.tsx b/src/components/ens-registration/IntroMarquee/IntroMarquee.tsx new file mode 100644 index 00000000000..c9f3592bd77 --- /dev/null +++ b/src/components/ens-registration/IntroMarquee/IntroMarquee.tsx @@ -0,0 +1,146 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +// @ts-expect-error +import { IS_TESTING } from 'react-native-dotenv'; +import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; +import { prefetchENSProfile } from '../../../hooks/useENSProfile'; +import { prefetchENSProfileImages } from '../../../hooks/useENSProfileImages'; +import ButtonPressAnimation from '../../animations/ButtonPressAnimation'; +import { MarqueeList } from '../../list'; +import { Box, Stack, Text } from '@rainbow-me/design-system'; +import { fetchRecords } from '@rainbow-me/handlers/ens'; +import { ImgixImage } from '@rainbow-me/images'; +import { useNavigation } from '@rainbow-me/navigation'; +import { ensIntroMarqueeNames } from '@rainbow-me/references'; +import Routes from '@rainbow-me/routes'; + +export const ensAvatarUrl = (ensName: string) => + `https://metadata.ens.domains/mainnet/avatar/${ensName}?v=1.0`; + +const lineHeight = 30; +const estimateDescriptionProfilePreviewHeight = (description?: string) => { + return description ? Math.ceil(description.length / 50) * lineHeight : 0; +}; + +export default function IntroMarquee() { + const { navigate } = useNavigation(); + const [introMarqueeProfiles, setIntroMarqueeProfiles] = useState<{ + [name: string]: string | undefined; + }>({}); + + const handlePressENS = useCallback( + (ensName: string) => { + navigate(Routes.PROFILE_PREVIEW_SHEET, { + address: ensName, + descriptionProfilePreviewHeight: estimateDescriptionProfilePreviewHeight( + introMarqueeProfiles[ensName] + ), + fromDiscover: true, + }); + }, + [introMarqueeProfiles, navigate] + ); + + const renderItem = useCallback( + ({ item, onPressStart, onPressCancel, testID }) => ( + + ), + [] + ); + + useEffect(() => { + const getProfiles = async () => { + const profiles: { [name: string]: string | undefined } = {}; + await Promise.all( + ensIntroMarqueeNames.map(async name => { + prefetchENSProfileImages({ name }); + prefetchENSProfile({ name }); + const records = await fetchRecords(name); + profiles[name] = records?.description; + }) + ); + setIntroMarqueeProfiles(profiles as any); + }; + if (IS_TESTING !== 'true') getProfiles(); + }, []); + + const items = useMemo( + () => + ensIntroMarqueeNames.map((name, index) => ({ + name, + onPress: () => handlePressENS(name), + testID: `ens-names-marquee-item-${index}`, + })), + [handlePressENS] + ); + + return ( + + + + ); +} + +function ENSAvatarPlaceholder({ + name, + onPress, + onPressCancel, + onPressStart, + testID, +}: { + name: string; + onPress: () => void; + onPressCancel: () => void; + onPressStart: () => void; + testID?: string; +}) { + return ( + { + // Ensure the press has been triggered + if (state === 5 && close) { + ReactNativeHapticFeedback.trigger('selection'); + onPress(); + } + }} + onPress={onPress} + onPressCancel={onPressCancel} + onPressStart={onPressStart} + reanimatedButton={false} + scaleTo={0.8} + testID={testID} + > + + + + + {name} + + + + + ); +} diff --git a/src/components/ens-registration/PendingRegistrations/PendingRegistrations.tsx b/src/components/ens-registration/PendingRegistrations/PendingRegistrations.tsx new file mode 100644 index 00000000000..6e0ebf3746c --- /dev/null +++ b/src/components/ens-registration/PendingRegistrations/PendingRegistrations.tsx @@ -0,0 +1,168 @@ +import lang from 'i18n-js'; +import React, { useCallback, useEffect } from 'react'; +import { Keyboard } from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; +import { Alert } from '../../../components/alerts'; +import ButtonPressAnimation from '../../../components/animations/ButtonPressAnimation'; +import ImageAvatar from '../../../components/contacts/ImageAvatar'; +import { + Box, + Column, + Columns, + Divider, + Inset, + Stack, + Text, +} from '@rainbow-me/design-system'; +import { RegistrationParameters } from '@rainbow-me/entities'; +import { REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; +import { + useENSPendingRegistrations, + useENSRegistration, +} from '@rainbow-me/hooks'; +import { useNavigation } from '@rainbow-me/navigation'; +import Routes from '@rainbow-me/routes'; +import { colors } from '@rainbow-me/styles'; + +const PendingRegistration = ({ + registration, + removeRegistration, + avatarUrl, +}: { + avatarUrl?: string; + registration: RegistrationParameters; + removeRegistration: (name: string) => void; +}) => { + const { navigate } = useNavigation(); + const { startRegistration } = useENSRegistration(); + + const onFinish = useCallback( + async (name: string) => { + startRegistration(name, REGISTRATION_MODES.CREATE); + android && Keyboard.dismiss(); + setTimeout(() => { + navigate(Routes.ENS_CONFIRM_REGISTER_SHEET, {}); + }, 100); + }, + [navigate, startRegistration] + ); + + const onRemove = useCallback( + async (name: string) => { + removeRegistration(name); + }, + [removeRegistration] + ); + + return ( + + + {avatarUrl && ( + + + + + + )} + + + + {registration.name} + + + + + + onFinish(registration.name)} + scaleTo={0.9} + > + + + + {lang.t('profiles.pending_registrations.finish')} + + + + + + + + onRemove(registration.name)} + scaleTo={0.9} + > + + 􀈒 + + + + + + ); +}; + +const PendingRegistrations = () => { + const { + pendingRegistrations, + removeRegistrationByName, + registrationImages, + removeExpiredRegistrations, + } = useENSPendingRegistrations(); + + useEffect(removeExpiredRegistrations, [removeExpiredRegistrations]); + + const removeRegistration = useCallback( + (name: string) => { + Alert({ + buttons: [ + { + style: 'cancel', + text: lang.t('profiles.pending_registrations.alert_cancel'), + }, + { + onPress: () => { + removeRegistrationByName(name); + }, + text: lang.t('profiles.pending_registrations.alert_confirm'), + }, + ], + message: lang.t('profiles.pending_registrations.alert_message'), + title: lang.t('profiles.pending_registrations.alert_title'), + }); + }, + [removeRegistrationByName] + ); + + return pendingRegistrations?.length > 0 ? ( + + + + + + + {lang.t('profiles.pending_registrations.in_progress')} + + {pendingRegistrations.map(registration => ( + + ))} + + + ) : null; +}; + +export default PendingRegistrations; diff --git a/src/components/ens-registration/RegistrationAvatar/RegistrationAvatar.tsx b/src/components/ens-registration/RegistrationAvatar/RegistrationAvatar.tsx new file mode 100644 index 00000000000..0611461e86a --- /dev/null +++ b/src/components/ens-registration/RegistrationAvatar/RegistrationAvatar.tsx @@ -0,0 +1,220 @@ +import { useFocusEffect } from '@react-navigation/core'; +import ConditionalWrap from 'conditional-wrap'; +import React, { useCallback, useEffect, useState } from 'react'; +import { + // @ts-ignore + IS_TESTING, +} from 'react-native-dotenv'; +import { Image } from 'react-native-image-crop-picker'; +import { atom, useSetRecoilState } from 'recoil'; +import ButtonPressAnimation from '../../animations/ButtonPressAnimation'; +import Skeleton from '../../skeleton/Skeleton'; +import AvatarCoverPhotoMaskSvg from '../../svg/AvatarCoverPhotoMaskSvg'; +import { + AccentColorProvider, + BackgroundProvider, + Box, + Cover, + Text, + useForegroundColor, +} from '@rainbow-me/design-system'; +import { UniqueAsset } from '@rainbow-me/entities'; +import { UploadImageReturnData } from '@rainbow-me/handlers/pinata'; +import { + useENSModifiedRegistration, + useENSRegistrationForm, + useSelectImageMenu, +} from '@rainbow-me/hooks'; +import { ImgixImage } from '@rainbow-me/images'; +import { useNavigation } from '@rainbow-me/navigation'; +import Routes from '@rainbow-me/routes'; +import { magicMemo, stringifyENSNFTRecord } from '@rainbow-me/utils'; + +export const avatarMetadataAtom = atom({ + default: undefined, + key: 'ens.avatarMetadata', +}); + +const size = 70; +const isTesting = IS_TESTING === 'true'; + +const RegistrationAvatar = ({ + hasSeenExplainSheet, + onChangeAvatarUrl, + onShowExplainSheet, +}: { + hasSeenExplainSheet: boolean; + onChangeAvatarUrl: (url: string) => void; + onShowExplainSheet: () => void; +}) => { + const { + images: { avatarUrl: initialAvatarUrl }, + } = useENSModifiedRegistration(); + const { + isLoading, + values, + onBlurField, + onRemoveField, + } = useENSRegistrationForm(); + const { navigate } = useNavigation(); + + const [avatarUpdateAllowed, setAvatarUpdateAllowed] = useState(true); + const [avatarUrl, setAvatarUrl] = useState( + initialAvatarUrl || values?.avatar + ); + useEffect(() => { + if (avatarUpdateAllowed) { + setAvatarUrl( + typeof initialAvatarUrl === 'string' ? initialAvatarUrl : values?.avatar + ); + } + }, [initialAvatarUrl, avatarUpdateAllowed]); // eslint-disable-line react-hooks/exhaustive-deps + + // We want to allow avatar state update when the screen is first focussed. + useFocusEffect(useCallback(() => setAvatarUpdateAllowed(true), [])); + + const setAvatarMetadata = useSetRecoilState(avatarMetadataAtom); + + const accentColor = useForegroundColor('accent'); + + const onChangeImage = useCallback( + ({ + asset, + image, + }: { + asset?: UniqueAsset; + image?: Image & { tmpPath?: string }; + }) => { + setAvatarMetadata(image); + setAvatarUrl(image?.tmpPath || asset?.image_thumbnail_url || ''); + // We want to disallow future avatar state changes (i.e. when upload successful) + // to avoid avatar flashing (from temp URL to uploaded URL). + setAvatarUpdateAllowed(false); + onChangeAvatarUrl(image?.path || asset?.image_thumbnail_url || ''); + if (asset) { + const standard = asset.asset_contract?.schema_name || ''; + const contractAddress = asset.asset_contract?.address || ''; + const tokenId = asset.id; + onBlurField({ + key: 'avatar', + value: stringifyENSNFTRecord({ + contractAddress, + standard, + tokenId, + }), + }); + } else if (image?.tmpPath) { + onBlurField({ + key: 'avatar', + value: image.tmpPath, + }); + } + }, + [onBlurField, onChangeAvatarUrl, setAvatarMetadata] + ); + + const { ContextMenu } = useSelectImageMenu({ + imagePickerOptions: { + cropperCircleOverlay: true, + cropping: true, + }, + menuItems: ['library', 'nft'], + onChangeImage, + onRemoveImage: () => { + onRemoveField({ key: 'avatar' }); + setAvatarUrl(''); + onChangeAvatarUrl(''); + setAvatarMetadata(undefined); + }, + onUploadError: () => { + onBlurField({ key: 'avatar', value: '' }); + setAvatarUrl(''); + }, + onUploadSuccess: ({ data }: { data: UploadImageReturnData }) => { + onBlurField({ key: 'avatar', value: data.url }); + }, + showRemove: Boolean(avatarUrl), + testID: 'avatar', + uploadToIPFS: true, + }); + + const handleSelectNFT = useCallback(() => { + navigate(Routes.SELECT_UNIQUE_TOKEN_SHEET, { + onSelect: (asset: any) => onChangeImage?.({ asset }), + springDamping: 1, + topOffset: 0, + }); + }, [navigate, onChangeImage]); + + return ( + + + + {({ backgroundColor }) => ( + + )} + + + {isLoading ? ( + + + + ) : ( + {children}} + > + + + + {avatarUrl ? ( + + ) : ( + + + {` 􀣵 `} + + + )} + + + + + )} + + ); +}; + +export default magicMemo(RegistrationAvatar, [ + 'hasSeenExplainSheet', + 'onChangeAvatarUrl', + 'onShowExplainSheet', +]); diff --git a/src/components/ens-registration/RegistrationCover/RegistrationCover.tsx b/src/components/ens-registration/RegistrationCover/RegistrationCover.tsx new file mode 100644 index 00000000000..308e0155156 --- /dev/null +++ b/src/components/ens-registration/RegistrationCover/RegistrationCover.tsx @@ -0,0 +1,166 @@ +import { useFocusEffect } from '@react-navigation/core'; +import ConditionalWrap from 'conditional-wrap'; +import lang from 'i18n-js'; +import React, { useCallback, useEffect, useState } from 'react'; +import { View } from 'react-native'; +import { Image } from 'react-native-image-crop-picker'; +import RadialGradient from 'react-native-radial-gradient'; +import { atom, useSetRecoilState } from 'recoil'; +import ButtonPressAnimation from '../../animations/ButtonPressAnimation'; +import Skeleton from '../../skeleton/Skeleton'; +import { Box, Text, useForegroundColor } from '@rainbow-me/design-system'; +import { UniqueAsset } from '@rainbow-me/entities'; +import { UploadImageReturnData } from '@rainbow-me/handlers/pinata'; +import { + useENSModifiedRegistration, + useENSRegistrationForm, + useSelectImageMenu, +} from '@rainbow-me/hooks'; +import { ImgixImage } from '@rainbow-me/images'; +import { magicMemo, stringifyENSNFTRecord } from '@rainbow-me/utils'; + +export const coverMetadataAtom = atom({ + default: undefined, + key: 'ens.coverMetadata', +}); + +const RegistrationCover = ({ + hasSeenExplainSheet, + onShowExplainSheet, +}: { + hasSeenExplainSheet: boolean; + onShowExplainSheet: () => void; +}) => { + const { + images: { coverUrl: initialCoverUrl }, + } = useENSModifiedRegistration(); + const { + isLoading, + onBlurField, + onRemoveField, + values, + } = useENSRegistrationForm(); + + const [coverUpdateAllowed, setCoverUpdateAllowed] = useState(true); + const [coverUrl, setCoverUrl] = useState(initialCoverUrl || values?.cover); + useEffect(() => { + if (coverUpdateAllowed) { + setCoverUrl( + typeof initialCoverUrl === 'string' ? initialCoverUrl : values?.cover + ); + } + }, [initialCoverUrl, coverUpdateAllowed, values, coverUrl]); + + // We want to allow cover state update when the screen is first focussed. + useFocusEffect(useCallback(() => setCoverUpdateAllowed(true), [])); + + const accentColor = useForegroundColor('accent'); + + const setCoverMetadata = useSetRecoilState(coverMetadataAtom); + + const { ContextMenu } = useSelectImageMenu({ + imagePickerOptions: { + cropping: true, + height: 500, + width: 1500, + }, + menuItems: ['library', 'nft'], + onChangeImage: ({ + asset, + image, + }: { + asset?: UniqueAsset; + image?: Image & { tmpPath?: string }; + }) => { + // We want to disallow future avatar state changes (i.e. when upload successful) + // to avoid avatar flashing (from temp URL to uploaded URL). + setCoverUpdateAllowed(false); + setCoverMetadata(image); + setCoverUrl(image?.tmpPath); + + if (asset) { + const standard = asset.asset_contract?.schema_name || ''; + const contractAddress = asset.asset_contract?.address || ''; + const tokenId = asset.id; + onBlurField({ + key: 'cover', + value: stringifyENSNFTRecord({ + contractAddress, + standard, + tokenId, + }), + }); + } else if (image?.tmpPath) { + onBlurField({ + key: 'cover', + value: image.tmpPath, + }); + } + }, + onRemoveImage: () => { + onRemoveField({ key: 'cover' }); + setCoverUrl(''); + setCoverMetadata(undefined); + }, + onUploadSuccess: ({ data }: { data: UploadImageReturnData }) => { + onBlurField({ key: 'cover', value: data.url }); + }, + showRemove: Boolean(coverUrl), + testID: 'cover', + uploadToIPFS: true, + }); + + if (isLoading) { + return ( + + + + + + ); + } + return ( + {children}} + > + + + {coverUrl ? ( + + ) : ( + + 􀣵 {lang.t('profiles.create.add_cover')} + + )} + + + + ); +}; + +export default magicMemo(RegistrationCover, [ + 'hasSeenExplainSheet', + 'onShowExplainSheet', +]); diff --git a/src/components/ens-registration/RegistrationReviewRows/RegistrationReviewRows.tsx b/src/components/ens-registration/RegistrationReviewRows/RegistrationReviewRows.tsx index e417d10f64d..6b30cf525c7 100644 --- a/src/components/ens-registration/RegistrationReviewRows/RegistrationReviewRows.tsx +++ b/src/components/ens-registration/RegistrationReviewRows/RegistrationReviewRows.tsx @@ -1,3 +1,4 @@ +import lang from 'i18n-js'; import React, { useCallback } from 'react'; import ButtonPressAnimation from '../../animations/ButtonPressAnimation'; import Skeleton, { FakeText } from '../../skeleton/Skeleton'; @@ -8,19 +9,44 @@ import { Inset, Stack, Text, + useForegroundColor, } from '@rainbow-me/design-system'; +import { REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; +import { useInterval } from '@rainbow-me/hooks'; + +const MIN_LONG_PRESS_DURATION = 200; +const LONG_PRESS_INTERVAL = 69; function StepButton({ + onLongPress, + onLongPressEnded, onPress, type, + disabled, }: { onPress: () => void; + onLongPress: () => void; + onLongPressEnded: () => void; type: 'increment' | 'decrement'; + disabled: boolean; }) { + const secondary15 = useForegroundColor('secondary') + '15'; + return ( - // @ts-ignore - - + + {type === 'increment' ? '􀁍' : '􀁏'} @@ -44,21 +70,57 @@ export default function RegistrationReviewRows({ networkFee, totalCost, registrationFee, + estimatedCostETH, + mode, + newExpiryDate, }: { maxDuration: number; duration: number; - onChangeDuration: (duration: number) => void; + onChangeDuration: React.Dispatch>; networkFee: string; totalCost: string; + estimatedCostETH: string; registrationFee: string; + newExpiryDate?: string; + mode: REGISTRATION_MODES.CREATE | REGISTRATION_MODES.RENEW; }) { + const [startLongPress, endLongPress] = useInterval(); + + const handlePressDecrement = useCallback( + () => + onChangeDuration(duration => (duration > 1 ? duration - 1 : duration)), + [onChangeDuration] + ); + + const handleLongPressDecrement = useCallback(() => { + startLongPress(handlePressDecrement, LONG_PRESS_INTERVAL); + }, [handlePressDecrement, startLongPress]); + + const handlePressIncrement = useCallback( + () => + onChangeDuration(duration => + duration < maxDuration ? duration + 1 : duration + ), + [maxDuration, onChangeDuration] + ); + + const handleLongPressIncrement = useCallback(() => { + startLongPress(() => handlePressIncrement(), LONG_PRESS_INTERVAL); + }, [handlePressIncrement, startLongPress]); + return ( - Register name for + {lang.t( + `profiles.confirm.${ + mode === REGISTRATION_MODES.CREATE + ? 'registration_duration' + : 'extend_by' + }` + )} @@ -66,30 +128,28 @@ export default function RegistrationReviewRows({ - duration > 1 - ? onChangeDuration(duration - 1) - : undefined, - [duration, onChangeDuration] - )} + disabled={duration === 1} + onLongPress={handleLongPressDecrement} + onLongPressEnded={endLongPress} + onPress={handlePressDecrement} type="decrement" /> - {duration} year{duration > 1 ? 's' : ''} + {duration > 1 + ? lang.t('profiles.confirm.duration_plural', { + content: duration, + }) + : lang.t('profiles.confirm.duration_singular')} - duration < maxDuration - ? onChangeDuration(duration + 1) - : undefined, - [duration, maxDuration, onChangeDuration] - )} + disabled={false} + onLongPress={handleLongPressIncrement} + onLongPressEnded={endLongPress} + onPress={handlePressIncrement} type="increment" /> @@ -97,10 +157,26 @@ export default function RegistrationReviewRows({ + + {mode === REGISTRATION_MODES.RENEW && ( + + + + {lang.t('profiles.confirm.new_expiration_date')} + + + + + {newExpiryDate} + + + + )} + - Registration cost + {lang.t('profiles.confirm.registration_cost')} @@ -113,10 +189,11 @@ export default function RegistrationReviewRows({ )} + - Estimated network fee + {lang.t('profiles.confirm.estimated_fees')} @@ -129,10 +206,35 @@ export default function RegistrationReviewRows({ )} + + {mode === REGISTRATION_MODES.CREATE && ( + + + + {lang.t('profiles.confirm.estimated_total_eth')} + + + + {networkFee ? ( + + {estimatedCostETH} ETH + + ) : ( + + )} + + + )} + - Total cost + {lang.t('profiles.confirm.estimated_total')} diff --git a/src/components/ens-registration/SearchInput/SearchInput.tsx b/src/components/ens-registration/SearchInput/SearchInput.tsx index 88935615828..d346b9efa06 100644 --- a/src/components/ens-registration/SearchInput/SearchInput.tsx +++ b/src/components/ens-registration/SearchInput/SearchInput.tsx @@ -1,5 +1,5 @@ import MaskedView from '@react-native-masked-view/masked-view'; -import React, { useMemo } from 'react'; +import React, { useMemo, useRef } from 'react'; import { TextInputProps } from 'react-native'; import Spinner from '../../Spinner'; import { Input } from '../../inputs'; @@ -13,14 +13,16 @@ import { Inset, useHeadingStyle, } from '@rainbow-me/design-system'; -import { useDimensions } from '@rainbow-me/hooks'; +import { useDimensions, useMagicAutofocus } from '@rainbow-me/hooks'; export type SearchInputProps = { isLoading?: boolean; onChangeText: TextInputProps['onChangeText']; value: TextInputProps['value']; - variant: 'rainbow'; + variant?: 'rainbow'; + selectionColor?: string; state?: 'success' | 'warning'; + testID: string; }; const SearchInput = ({ @@ -28,11 +30,23 @@ const SearchInput = ({ onChangeText, value, variant = 'rainbow', + selectionColor, state, + testID, }: SearchInputProps) => { const { width: deviceWidth } = useDimensions(); const headingStyle = useHeadingStyle({ size: '30px', weight: 'heavy' }); + const inputRef = useRef(); + const { handleFocus } = useMagicAutofocus( + inputRef, + undefined, + // On Android, should show keyboard upon navigation focus. + true, + // On iOS, defer keyboard display until interactions finished (screen transition). + ios + ); + const height = 64; const strokeWidth = 3; @@ -112,9 +126,13 @@ const SearchInput = ({ ({ ...headingStyle, @@ -123,10 +141,11 @@ const SearchInput = ({ }), [headingStyle] )} + testID={testID} value={value} /> - + .eth diff --git a/src/components/ens-registration/SearchResult/SearchResultGradientIndicator.tsx b/src/components/ens-registration/SearchResult/SearchResultGradientIndicator.tsx index a68489ed839..592aeb32d73 100644 --- a/src/components/ens-registration/SearchResult/SearchResultGradientIndicator.tsx +++ b/src/components/ens-registration/SearchResult/SearchResultGradientIndicator.tsx @@ -1,3 +1,4 @@ +import lang from 'i18n-js'; import React from 'react'; import LinearGradient from 'react-native-linear-gradient'; import { useTheme } from '@rainbow-me/context'; @@ -14,6 +15,7 @@ type Props = { isRegistered?: boolean; price?: string; expirationDate?: string; + testID?: string; }; const SearchResultGradientIndicator = ({ @@ -21,6 +23,7 @@ const SearchResultGradientIndicator = ({ isRegistered = false, price, expirationDate, + testID, }: Props) => { const { colors } = useTheme(); const { isSmallPhone } = useDimensions(); @@ -28,19 +31,21 @@ const SearchResultGradientIndicator = ({ switch (type) { case 'availability': if (isRegistered) { - text = '😭 Taken'; + text = lang.t('profiles.search.taken'); gradient = colors.gradients.transparentToLightOrange; } else { - text = '🥳 Available'; + text = lang.t('profiles.search.available'); gradient = colors.gradients.transparentToGreen; } break; case 'expiration': - text = `Til ${expirationDate}`; + text = `${lang.t('profiles.search.expiration', { + content: expirationDate, + })}`; gradient = colors.gradients.transparentToLightGrey; break; case 'price': - text = `${price} / Year`; + text = `${lang.t('profiles.search.price', { content: price })}`; gradient = colors.gradients.transparentToLightGrey; break; } @@ -64,6 +69,7 @@ const SearchResultGradientIndicator = ({ color={type === 'availability' ? 'accent' : 'secondary80'} containsEmoji size={isSmallPhone ? '18px' : '20px'} + testID={testID} weight="heavy" > {text} diff --git a/src/components/ens-registration/TextRecordsForm/SelectableButton.tsx b/src/components/ens-registration/TextRecordsForm/SelectableButton.tsx index dc868b340dc..d94e2b1e394 100644 --- a/src/components/ens-registration/TextRecordsForm/SelectableButton.tsx +++ b/src/components/ens-registration/TextRecordsForm/SelectableButton.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useMemo } from 'react'; +import React, { ReactNode } from 'react'; import ButtonPressAnimation from '../../animations/ButtonPressAnimation'; import { AccentColorProvider, @@ -11,25 +11,30 @@ type SelectableButtonProps = { children: ReactNode; onSelect: () => void; isSelected: boolean; + testID?: string; }; export default function SelectableButton({ children, onSelect, isSelected, + testID, }: SelectableButtonProps) { + const secondary06 = useForegroundColor('secondary06'); const secondary30 = useForegroundColor('secondary30'); const accent = useForegroundColor('accent'); - const buttonColor = isSelected ? accent : secondary30; + const borderColor = isSelected ? accent : secondary06; + const textColor = isSelected ? accent : secondary30; const height = 30; return ( - + ({ borderColor: buttonColor, borderWidth: 2 }), [ - buttonColor, - ])} + style={{ borderColor: borderColor, borderWidth: 2 }} > - + {children} diff --git a/src/components/ens-registration/TextRecordsForm/TextRecordsForm.tsx b/src/components/ens-registration/TextRecordsForm/TextRecordsForm.tsx index caa58245e57..d392db4a981 100644 --- a/src/components/ens-registration/TextRecordsForm/TextRecordsForm.tsx +++ b/src/components/ens-registration/TextRecordsForm/TextRecordsForm.tsx @@ -1,41 +1,151 @@ -import React from 'react'; -import InlineField from '../../inputs/InlineField'; -import { Box, Divider } from '@rainbow-me/design-system'; -import { TextRecordField } from '@rainbow-me/helpers/ens'; - -type TextRecordsFormProps = { - onBlurField: ({ key, value }: { key: string; value: string }) => void; - onChangeField: ({ key, value }: { key: string; value: string }) => void; - selectedFields: TextRecordField[]; - values: any; -}; +import { debounce, isEmpty } from 'lodash'; +import React, { useCallback, useEffect, useState } from 'react'; +import { TextInputProps, ViewProps } from 'react-native'; +import InlineField, { InlineFieldProps } from '../../inputs/InlineField'; +import Skeleton, { FakeText } from '../../skeleton/Skeleton'; +import { + Box, + Column, + Columns, + Divider, + Stack, +} from '@rainbow-me/design-system'; +import { useENSRegistrationForm } from '@rainbow-me/hooks'; export default function TextRecordsForm({ - onBlurField, - onChangeField, - selectedFields, - values, -}: TextRecordsFormProps) { + autoFocusKey, + onAutoFocusLayout, + onFocus, + onError, + selectionColor, +}: { + autoFocusKey?: string; + onAutoFocusLayout?: ViewProps['onLayout']; + onFocus?: TextInputProps['onFocus']; + onError?: ({ yOffset }: { yOffset: number }) => void; + selectionColor?: string; +}) { + const { + errors, + isLoading, + selectedFields, + onChangeField, + onBlurField, + submitting, + values, + } = useENSRegistrationForm(); + + const [yOffsets, setYOffsets] = useState<{ [key: string]: number }>({}); + + useEffect(() => { + if (!isEmpty(errors)) { + const firstErrorKey = Object.keys(errors)[0]; + onError?.({ yOffset: yOffsets[firstErrorKey] || 0 }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...Object.keys(errors), onError, yOffsets, submitting]); + + const handleLayout = useCallback( + (e, key) => { + const yOffset = e.nativeEvent?.layout.y; + setYOffsets(yOffsets => ({ + ...yOffsets, + [key]: yOffset, + })); + if (autoFocusKey === key) { + onAutoFocusLayout?.(e); + } + }, + [autoFocusKey, onAutoFocusLayout] + ); + return ( - {selectedFields.map( - ({ label, inputProps, placeholder, validations, id, key }) => ( - - - onChangeField({ key, value: text })} - onEndEditing={({ nativeEvent }) => { - onBlurField({ key, value: nativeEvent.text }); - }} - placeholder={placeholder} - validations={validations} - /> - - ) + {isLoading ? ( + + + + + + + + + + + + ) : ( + <> + {selectedFields.map( + ({ + label, + inputProps, + placeholder, + startsWith, + validations, + id, + key, + }) => ( + handleLayout(e, key)}> + onChangeField({ key, value: text }), + 300 + )} + onEndEditing={({ nativeEvent }) => { + onBlurField({ key, value: nativeEvent.text }); + }} + onFocus={onFocus} + placeholder={placeholder} + selectionColor={selectionColor} + startsWith={startsWith} + testID={`ens-text-record-${key}`} + validations={validations} + /> + + ) + )} + )} ); } + +function Field({ defaultValue, ...props }: InlineFieldProps) { + const [value, setValue] = useState(defaultValue); + + // Set / clear values when the screen comes to focus / unfocus. + useEffect(() => { + setValue(defaultValue); + }, [defaultValue]); + + return ( + <> + + { + props.onChangeText(text); + setValue(text); + }} + value={value} + /> + + ); +} + +function FakeField() { + return ( + + + + + + + ); +} diff --git a/src/components/ens-registration/index.tsx b/src/components/ens-registration/index.tsx index 2f222e09068..060cc076a8a 100644 --- a/src/components/ens-registration/index.tsx +++ b/src/components/ens-registration/index.tsx @@ -1,5 +1,13 @@ +export { default as RegistrationAvatar } from './RegistrationAvatar/RegistrationAvatar'; +export { default as RegistrationCover } from './RegistrationCover/RegistrationCover'; export { default as RegistrationReviewRows } from './RegistrationReviewRows/RegistrationReviewRows'; export { default as SearchResultGradientIndicator } from './SearchResult/SearchResultGradientIndicator'; export { default as SearchInputGradientBackground } from './SearchInput/SearchInputGradientBackground'; export { default as SearchInput } from './SearchInput/SearchInput'; export { default as TextRecordsForm } from './TextRecordsForm/TextRecordsForm'; +export { default as PendingRegistrations } from './PendingRegistrations/PendingRegistrations'; +export { default as WaitCommitmentConfirmationContent } from './ConfirmContent/WaitCommitmentConfirmationContent'; +export { default as WaitENSConfirmationContent } from './ConfirmContent/WaitENSConfirmationContent'; +export { default as RegisterContent } from './ConfirmContent/RegisterContent'; +export { default as CommitContent } from './ConfirmContent/CommitContent'; +export { default as RenewContent } from './ConfirmContent/RenewContent'; diff --git a/src/components/exchange/CurrencySelectionList.js b/src/components/exchange/CurrencySelectionList.js index e787faa354f..b3aa6d4ad9e 100644 --- a/src/components/exchange/CurrencySelectionList.js +++ b/src/components/exchange/CurrencySelectionList.js @@ -39,6 +39,7 @@ const CurrencySelectionList = ( listItems, loading, query, + scrollIndicatorInsets, showList, testID, }, @@ -75,6 +76,7 @@ const CurrencySelectionList = ( keyboardDismissMode={keyboardDismissMode} query={query} ref={ref} + scrollIndicatorInsets={scrollIndicatorInsets} testID={testID} /> )} diff --git a/src/components/exchange/ExchangeAssetList.js b/src/components/exchange/ExchangeAssetList.js index 91fb57205a7..625c0eae68b 100644 --- a/src/components/exchange/ExchangeAssetList.js +++ b/src/components/exchange/ExchangeAssetList.js @@ -63,7 +63,6 @@ const HeaderTitleWrapper = styled.View({}); const contentContainerStyle = { paddingBottom: 9.5 }; const keyExtractor = ({ uniqueId }) => `ExchangeAssetList-${uniqueId}`; -const scrollIndicatorInsets = { bottom: 24 }; const getItemLayout = ({ showBalance }, index) => { const height = showBalance ? CoinRowHeight + 1 : CoinRowHeight; return { @@ -102,7 +101,6 @@ const ExchangeAssetSectionList = styled(SectionList).attrs({ keyExtractor, maxToRenderPerBatch: 50, scrollEventThrottle: 32, - scrollIndicatorInsets, windowSize: 41, })({ height: '100%', @@ -116,6 +114,7 @@ const ExchangeAssetList = ( items, onLayout, query, + scrollIndicatorInsets = { bottom: 24 }, testID, }, ref @@ -199,12 +198,14 @@ const ExchangeAssetList = ( ({ item }) => { return item.ens ? ( ) : ( @@ -249,6 +250,7 @@ const ExchangeAssetList = ( ref={sectionListRef} renderItem={renderItemCallback} renderSectionHeader={ExchangeAssetSectionListHeader} + scrollIndicatorInsets={scrollIndicatorInsets} scrollsToTop={isFocused} sections={sections} /> diff --git a/src/components/exchange/ExchangeSearch.js b/src/components/exchange/ExchangeSearch.js index edd849db054..956d04b64f4 100644 --- a/src/components/exchange/ExchangeSearch.js +++ b/src/components/exchange/ExchangeSearch.js @@ -46,26 +46,25 @@ const BackgroundGradient = styled(RadialGradient).attrs( }); const SearchIcon = styled(Text).attrs(({ theme: { colors } }) => ({ - color: colors.alpha(colors.blueGreyDark, 0.5), + color: colors.alpha(colors.blueGreyDark, 0.6), size: 'large', weight: 'semibold', }))({}); const SearchIconWrapper = styled(Animated.View)({ - marginTop: android ? 5 : 8, + marginTop: android ? 6 : 9, }); const SearchInput = styled(Input).attrs( ({ theme: { colors }, isSearchModeEnabled, clearTextOnFocus }) => ({ - autoCapitalize: 'words', blurOnSubmit: false, clearTextOnFocus, color: colors.alpha(colors.blueGreyDark, 0.8), enablesReturnKeyAutomatically: true, keyboardAppearance: 'dark', keyboardType: 'ascii-capable', - lineHeight: 'loose', - placeholderTextColor: colors.alpha(colors.blueGreyDark, 0.5), + lineHeight: 'looserLoose', + placeholderTextColor: colors.alpha(colors.blueGreyDark, 0.6), returnKeyType: 'search', selectionColor: isSearchModeEnabled ? colors.appleBlue : colors.transparent, size: 'large', @@ -75,9 +74,9 @@ const SearchInput = styled(Input).attrs( )({ ...(android ? { marginBottom: -10, marginTop: -6 } : {}), flex: 1, - height: ios ? 38 : 56, + height: ios ? 39 : 56, marginBottom: 1, - marginLeft: ({ isSearchModeEnabled }) => (isSearchModeEnabled ? 3 : 0), + marginLeft: ({ isSearchModeEnabled }) => (isSearchModeEnabled ? 4 : 0), textAlign: ({ isSearchModeEnabled }) => isSearchModeEnabled ? 'left' : 'center', }); @@ -95,7 +94,7 @@ const SearchSpinnerWrapper = styled(Animated.View)({ height: 20, left: 12, position: 'absolute', - top: 9.5, + top: 10, width: 20, }); @@ -117,7 +116,7 @@ const ExchangeSearch = ( onFocus, searchQuery, testID, - placeholderText = lang.t('button.exchange_search_uniswap'), + placeholderText = lang.t('button.exchange_search_placeholder'), clearTextOnFocus = true, }, ref diff --git a/src/components/expanded-state/ContactProfileState.js b/src/components/expanded-state/ContactProfileState.js index 1215f0454fd..382c462a0e0 100644 --- a/src/components/expanded-state/ContactProfileState.js +++ b/src/components/expanded-state/ContactProfileState.js @@ -1,216 +1,112 @@ import lang from 'i18n-js'; -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Keyboard } from 'react-native'; import { useTheme } from '../../context/ThemeContext'; import { useNavigation } from '../../navigation/Navigation'; -import { abbreviations, magicMemo, profileUtils } from '../../utils'; -import Divider from '../Divider'; -import { ButtonPressAnimation } from '../animations'; -import { Button } from '../buttons'; -import { showDeleteContactActionSheet } from '../contacts'; -import CopyTooltip from '../copy-tooltip'; -import { Centered } from '../layout'; -import { Text, TruncatedAddress, TruncatedENS } from '../text'; -import { ProfileAvatarButton, ProfileModal, ProfileNameInput } from './profile'; +import { magicMemo } from '../../utils'; +import ProfileModal from './profile/ProfileModal'; +import useExperimentalFlag, { + PROFILES, +} from '@rainbow-me/config/experimentalHooks'; +import { maybeSignUri } from '@rainbow-me/handlers/imgix'; import { removeFirstEmojiFromString, returnStringFirstEmoji, } from '@rainbow-me/helpers/emojiHandler'; -import { isValidDomainFormat } from '@rainbow-me/helpers/validators'; -import { useAccountSettings, useContacts } from '@rainbow-me/hooks'; -import styled from '@rainbow-me/styled-components'; -import { margin, padding } from '@rainbow-me/styles'; - -const AddressAbbreviation = styled(TruncatedAddress).attrs( - ({ theme: { colors } }) => ({ - align: 'center', - color: colors.blueGreyDark, - firstSectionLength: abbreviations.defaultNumCharsPerSection, - size: 'lmedium', - truncationLength: 4, - weight: 'regular', - }) -)({ - ...margin.object(9, 0, 5), - opacity: 0.6, - width: '100%', -}); - -const ENSAbbreviation = styled(TruncatedENS).attrs(({ theme: { colors } }) => ({ - align: 'center', - color: colors.blueGreyDark, - size: 'lmedium', - truncationLength: 18, - weight: 'regular', -}))({ - ...margin.object(9, 0, 5), - opacity: 0.6, - width: '100%', -}); - -const Spacer = styled.View({ - height: 19, -}); +import { + useAccountSettings, + useContacts, + useENSProfileImages, + usePersistentDominantColorFromImage, +} from '@rainbow-me/hooks'; +import { + addressHashedColorIndex, + addressHashedEmoji, +} from '@rainbow-me/utils/profileUtils'; -const SubmitButton = styled(Button).attrs( - ({ theme: { colors }, value, color }) => ({ - backgroundColor: - value.length > 0 - ? typeof color === 'string' - ? color - : colors.avatarBackgrounds[color] || colors.appleBlue - : undefined, - disabled: !value.length > 0, - showShadow: true, - size: 'small', - }) -)({ - height: 43, - width: 215, -}); +const ContactProfileState = ({ address, color, contact, ens, nickname }) => { + const profilesEnabled = useExperimentalFlag(PROFILES); + const contactNickname = contact?.nickname || nickname; + const { goBack } = useNavigation(); + const { onAddOrUpdateContacts } = useContacts(); + const { colors } = useTheme(); -const SubmitButtonLabel = styled(Text).attrs(({ value }) => ({ - color: value.length > 0 ? 'whiteLabel' : 'white', - size: 'lmedium', - weight: 'bold', -}))({ - marginBottom: 1.5, -}); + const [value, setValue] = useState( + profilesEnabled + ? contactNickname + : removeFirstEmojiFromString(contactNickname) + ); -const centerdStyles = padding.object(24, 25); -const bottomStyles = padding.object(8, 9); + const emoji = useMemo( + () => + profilesEnabled + ? addressHashedEmoji(address) + : returnStringFirstEmoji(contactNickname), + [address, contactNickname, profilesEnabled] + ); -const ContactProfileState = ({ address, color: colorProp, contact }) => { - const { goBack } = useNavigation(); - const { onAddOrUpdateContacts, onRemoveContact } = useContacts(); - const [color, setColor] = useState(colorProp || 0); - const [value, setValue] = useState( - removeFirstEmojiFromString(contact?.nickname || '') + const colorIndex = useMemo( + () => (profilesEnabled ? addressHashedColorIndex(address) : color || 0), + [address, color, profilesEnabled] ); - const [emoji, setEmoji] = useState(returnStringFirstEmoji(contact?.nickname)); - const inputRef = useRef(null); + const { network } = useAccountSettings(); const handleAddContact = useCallback(() => { - const nickname = (emoji ? `${emoji} ${value}` : value).trim(); - if (value.length > 0 || color !== colorProp) { - onAddOrUpdateContacts(address, nickname, color, network); + const nickname = profilesEnabled + ? value + : (emoji ? `${emoji} ${value}` : value).trim(); + if (value.length > 0) { + onAddOrUpdateContacts(address, nickname, color, network, ens); goBack(); } android && Keyboard.dismiss(); }, [ address, color, - colorProp, emoji, + ens, goBack, network, onAddOrUpdateContacts, + profilesEnabled, value, ]); - const handleDeleteContact = useCallback(() => { - showDeleteContactActionSheet({ - address, - nickname: value, - onDelete: goBack, - removeContact: onRemoveContact, - }); + const handleCancel = useCallback(() => { + goBack(); android && Keyboard.dismiss(); - }, [address, goBack, onRemoveContact, value]); + }, [goBack]); - const handleTriggerFocusInput = useCallback(() => inputRef.current?.focus(), [ - inputRef, - ]); + const { data: images } = useENSProfileImages(ens, { + enabled: Boolean(ens), + }); - const isContact = contact && !contact.temporary; + const avatarUrl = profilesEnabled ? images?.avatarUrl : undefined; - const { isDarkMode, colors } = useTheme(); + const { result: dominantColor } = usePersistentDominantColorFromImage( + maybeSignUri(avatarUrl || '') || '' + ); - const handleChangeAvatar = useCallback(() => { - const prevAvatarIndex = profileUtils.avatars.findIndex( - avatar => avatar.emoji === emoji - ); - const nextAvatarIndex = (prevAvatarIndex + 1) % profileUtils.avatars.length; - setColor(profileUtils.avatars[nextAvatarIndex]?.colorIndex); - setEmoji(profileUtils.avatars[nextAvatarIndex]?.emoji); - }, [emoji, setColor]); + const accentColor = + dominantColor || colors.avatarBackgrounds[colorIndex || 0]; return ( - - - - - - - {isValidDomainFormat(address) ? ( - - ) : ( - - )} - - - - - - - {isContact ? lang.t('button.done') : lang.t('contacts.options.add')} - - - { - goBack(); - android && Keyboard.dismiss(); - } - } - > - - - {isContact - ? lang.t('contacts.options.delete') - : lang.t('contacts.options.cancel')} - - - - - + ); }; diff --git a/src/components/expanded-state/UniqueTokenExpandedState.tsx b/src/components/expanded-state/UniqueTokenExpandedState.tsx index 02562d12c5d..dfd5da3fe2e 100644 --- a/src/components/expanded-state/UniqueTokenExpandedState.tsx +++ b/src/components/expanded-state/UniqueTokenExpandedState.tsx @@ -1,18 +1,11 @@ import { BlurView } from '@react-native-community/blur'; import c from 'chroma-js'; import lang from 'i18n-js'; -import React, { - Fragment, - ReactNode, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { Linking, Share, View } from 'react-native'; +import React, { ReactNode, useCallback, useMemo, useRef } from 'react'; +import { InteractionManager, Linking, Share, View } from 'react-native'; import Animated, { useAnimatedStyle, + useDerivedValue, useSharedValue, } from 'react-native-reanimated'; import URL from 'url-parse'; @@ -22,6 +15,8 @@ import { lightModeThemeColors } from '../../styles/colors'; import L2Disclaimer from '../L2Disclaimer'; import Link from '../Link'; import { ButtonPressAnimation } from '../animations'; +import ImagePreviewOverlay from '../images/ImagePreviewOverlay'; +import ImgixImage from '../images/ImgixImage'; import { SendActionButton, SheetActionButton, @@ -29,16 +24,22 @@ import { SlackSheet, } from '../sheet'; import { ToastPositionContainer, ToggleStateToast } from '../toasts'; -import { TokenInfoItem } from '../token-info'; import { UniqueTokenAttributes, UniqueTokenImage } from '../unique-token'; +import AdvancedSection from './ens/AdvancedSection'; +import ConfigurationSection from './ens/ConfigurationSection'; +import ProfileInfoSection from './ens/ProfileInfoSection'; import { UniqueTokenExpandedStateContent, UniqueTokenExpandedStateHeader, } from './unique-token'; +import ENSBriefTokenInfoRow from './unique-token/ENSBriefTokenInfoRow'; +import NFTBriefTokenInfoRow from './unique-token/NFTBriefTokenInfoRow'; +import { PROFILES, useExperimentalFlag } from '@rainbow-me/config'; import { useTheme } from '@rainbow-me/context'; import { AccentColorProvider, Bleed, + Box, ColorModeProvider, Columns, Divider, @@ -53,13 +54,14 @@ import { TextProps, } from '@rainbow-me/design-system'; import { AssetTypes, UniqueAsset } from '@rainbow-me/entities'; -import { apiGetUniqueTokenFloorPrice } from '@rainbow-me/handlers/opensea-api'; import { buildUniqueTokenName } from '@rainbow-me/helpers/assets'; +import { ENS_RECORDS, REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; import { useAccountProfile, - useAccountSettings, useBooleanState, useDimensions, + useENSProfile, + useENSRegistration, usePersistentDominantColorFromImage, useShowcaseTokens, } from '@rainbow-me/hooks'; @@ -67,10 +69,8 @@ import { useNavigation, useUntrustedUrlOpener } from '@rainbow-me/navigation'; import Routes from '@rainbow-me/routes'; import styled from '@rainbow-me/styled-components'; import { position } from '@rainbow-me/styles'; -import { convertAmountToNativeDisplay } from '@rainbow-me/utilities'; import { buildRainbowUrl, - ethereumUtils, magicMemo, safeAreaInsetValues, } from '@rainbow-me/utils'; @@ -112,10 +112,14 @@ const TextButton = ({ onPress, children, align, + size = '16px', + weight = 'heavy', }: { onPress: () => void; children: ReactNode; align?: TextProps['align']; + size?: TextProps['size']; + weight?: TextProps['weight']; }) => { const hitSlop: Space = '19px'; @@ -123,7 +127,7 @@ const TextButton = ({ - + {children} @@ -135,18 +139,51 @@ const TextButton = ({ const textSize: TextProps['size'] = '18px'; const textColor: TextProps['color'] = 'secondary50'; const sectionSpace: Space = '30px'; -const paragraphSpace: Space = '24px'; +const paragraphSpace: Space = { custom: 22 }; const listSpace: Space = '19px'; const Section = ({ + addonComponent, + paragraphSpace = '24px', title, + titleEmoji, + titleImageUrl, children, }: { + addonComponent?: React.ReactNode; + paragraphSpace?: Space; title: string; + titleEmoji?: string; + titleImageUrl?: string | null; children: ReactNode; }) => ( - {title} + + + + {titleImageUrl && ( + + + + )} + {titleEmoji && ( + + + {titleEmoji} + + + )} + + {title} + + {addonComponent} + {children} ); @@ -171,6 +208,12 @@ const Markdown = ({ ); }; +export enum UniqueTokenType { + NFT = 'NFT', + ENS = 'ENS', + POAP = 'POAP', +} + interface UniqueTokenExpandedStateProps { asset: UniqueAsset; external: boolean; @@ -181,7 +224,6 @@ const UniqueTokenExpandedState = ({ external, }: UniqueTokenExpandedStateProps) => { const { accountAddress, accountENS } = useAccountProfile(); - const { nativeCurrency, network } = useAccountSettings(); const { height: deviceHeight, width: deviceWidth } = useDimensions(); const { navigate } = useNavigation(); const { colors, isDarkMode } = useTheme(); @@ -191,8 +233,8 @@ const UniqueTokenExpandedState = ({ collection: { description: familyDescription, external_url: familyLink }, currentPrice, description, + familyImage, familyName, - isPoap, isSendable, lastPrice, lastSalePaymentToken, @@ -201,27 +243,58 @@ const UniqueTokenExpandedState = ({ urlSuffixForAsset, } = asset; + const uniqueTokenType = useMemo(() => { + if (asset.isPoap) return UniqueTokenType.POAP; + if (familyName === 'ENS' && uniqueId !== 'Unknown ENS name') { + return UniqueTokenType.ENS; + } + return UniqueTokenType.NFT; + }, [asset.isPoap, familyName, uniqueId]); + + // Create deterministic boolean flags from the `uniqueTokenType` (for easier readability). + const isPoap = uniqueTokenType === UniqueTokenType.POAP; + const isENS = uniqueTokenType === UniqueTokenType.ENS; + const isNFT = uniqueTokenType === UniqueTokenType.NFT; + + // Fetch the ENS profile if the unique token is an ENS name. + const cleanENSName = isENS && uniqueId ? uniqueId?.split(' ')?.[0] : uniqueId; + const ensProfile = useENSProfile(cleanENSName, { enabled: isENS }); + const ensData = ensProfile.data; + + const profileInfoSectionAvailable = useMemo(() => { + const available = Object.keys(ensData?.records || {}).some( + key => key !== ENS_RECORDS.avatar + ); + return available; + }, [ensData?.records]); + const { addShowcaseToken, removeShowcaseToken, showcaseTokens, } = useShowcaseTokens(); - const [floorPrice, setFloorPrice] = useState(null); - const [showCurrentPriceInEth, setShowCurrentPriceInEth] = useState(true); - const [showFloorInEth, setShowFloorInEth] = useState(true); const [ contentFocused, handleContentFocus, handleContentBlur, ] = useBooleanState(); const animationProgress = useSharedValue(0); + const ensCoverAnimationProgress = useSharedValue(0); + // TODO(jxom): This is temporary until `ZoomableWrapper` refactor const opacityStyle = useAnimatedStyle(() => ({ - opacity: 1 - animationProgress.value, + opacity: 1 - (animationProgress.value || ensCoverAnimationProgress.value), })); + // TODO(jxom): This is temporary until `ZoomableWrapper` refactor const sheetHandleStyle = useAnimatedStyle(() => ({ - opacity: 1 - animationProgress.value, + opacity: 1 - (animationProgress.value || ensCoverAnimationProgress.value), })); + // TODO(jxom): This is temporary until `ZoomableWrapper` refactor + const contentOpacity = useDerivedValue( + () => 1 - ensCoverAnimationProgress.value + ); + // TODO(jxom): This is temporary until `ZoomableWrapper` refactor + const ensCoverOpacity = useDerivedValue(() => 1 - animationProgress.value); const handleL2DisclaimerPress = useCallback(() => { navigate(Routes.EXPLAIN_SHEET, { @@ -239,14 +312,6 @@ const UniqueTokenExpandedState = ({ usePersistentDominantColorFromImage(asset.lowResUrl).result || colors.paleBlue; - const lastSalePrice = - lastPrice != null - ? lastPrice === 0 - ? `< 0.001 ${lastSalePaymentToken}` - : `${lastPrice} ${lastSalePaymentToken}` - : 'None'; - const priceOfEth = ethereumUtils.getEthPriceUnit() as number; - const textColor = useMemo(() => { const contrastWithWhite = c.contrast(imageColor, colors.whiteLabel); @@ -257,20 +322,6 @@ const UniqueTokenExpandedState = ({ } }, [colors.whiteLabel, imageColor]); - useEffect(() => { - !isPoap && - asset.network !== AssetTypes.polygon && - apiGetUniqueTokenFloorPrice(network, urlSuffixForAsset).then(result => { - setFloorPrice(result); - }); - }, [asset.network, isPoap, network, urlSuffixForAsset]); - - const handlePressCollectionFloor = useCallback(() => { - navigate(Routes.EXPLAIN_SHEET, { - type: 'floor_price', - }); - }, [navigate]); - const handlePressOpensea = useCallback( () => Linking.openURL(asset.permalink), [asset.permalink] @@ -294,20 +345,30 @@ const UniqueTokenExpandedState = ({ }); }, [accountAddress, accountENS, asset]); - const toggleCurrentPriceDisplayCurrency = useCallback( - () => setShowCurrentPriceInEth(!showCurrentPriceInEth), - [showCurrentPriceInEth, setShowCurrentPriceInEth] - ); - - const toggleFloorDisplayCurrency = useCallback( - () => setShowFloorInEth(!showFloorInEth), - [showFloorInEth, setShowFloorInEth] - ); + const { startRegistration } = useENSRegistration(); + const handlePressEdit = useCallback(() => { + if (isENS) { + InteractionManager.runAfterInteractions(() => { + startRegistration(uniqueId, REGISTRATION_MODES.EDIT); + navigate(Routes.REGISTER_ENS_NAVIGATOR, { + ensName: uniqueId, + externalAvatarUrl: asset?.lowResUrl, + mode: REGISTRATION_MODES.EDIT, + }); + }); + } + }, [isENS, navigate, startRegistration, uniqueId, asset?.lowResUrl]); const sheetRef = useRef(); const yPosition = useSharedValue(0); - const hasSendButton = !external && !isReadOnlyWallet && isSendable; + const profilesEnabled = useExperimentalFlag(PROFILES); + const isActionsEnabled = !external && !isReadOnlyWallet; + const hasSendButton = isActionsEnabled && isSendable; + + const hasEditButton = + isActionsEnabled && profilesEnabled && isENS && ensProfile.isOwner; + const hasExtendDurationButton = !isReadOnlyWallet && profilesEnabled && isENS; const familyLinkDisplay = useMemo( () => @@ -316,7 +377,7 @@ const UniqueTokenExpandedState = ({ ); return ( - + <> {ios && ( @@ -346,197 +407,275 @@ const UniqueTokenExpandedState = ({ ref={sheetRef} scrollEnabled showsVerticalScrollIndicator={!contentFocused} + testID="unique-token-expanded-state" yPosition={yPosition} > - - - - {/* @ts-expect-error JavaScript component */} - - - - - - - - - - - - {isShowcaseAsset - ? `􀁏 ${lang.t( - 'expanded_state.unique_expanded.in_showcase' - )}` - : `􀁍 ${lang.t( - 'expanded_state.unique_expanded.showcase' - )}`} - - - 􀈂 {lang.t('button.share')} - - - - - {!isPoap ? ( - - - {hasSendButton ? ( - - ) : null} - - ) : null} - {asset.network === AssetTypes.polygon ? ( - // @ts-expect-error JavaScript component - + + + + {/* @ts-expect-error JavaScript component */} + - ) : null} - } - space={sectionSpace} - > - {!isPoap && asset.network !== AssetTypes.polygon ? ( - - - {/* @ts-expect-error JavaScript component */} - - {showCurrentPriceInEth || - nativeCurrency === 'ETH' || - !currentPrice - ? currentPrice || lastSalePrice - : convertAmountToNativeDisplay( - // @ts-expect-error currentPrice is a number? - parseFloat(currentPrice) * priceOfEth, - nativeCurrency - )} - - {/* @ts-expect-error JavaScript component */} - + + + + + + + + + + {isShowcaseAsset + ? `􀁏 ${lang.t( + 'expanded_state.unique_expanded.in_showcase' + )}` + : `􀁍 ${lang.t( + 'expanded_state.unique_expanded.showcase' + )}`} + + + 􀈂 {lang.t('button.share')} + + + + + {isNFT || isENS ? ( + + {hasEditButton ? ( + + ) : asset.permalink ? ( + - {showFloorInEth || - nativeCurrency === 'ETH' || - floorPrice === 'None' || - floorPrice === null - ? floorPrice - : convertAmountToNativeDisplay( - parseFloat(floorPrice) * priceOfEth, - nativeCurrency - )} - - - - ) : null} - {description ? ( -
- {description} -
+ nftShadows + onPress={handlePressOpensea} + textColor={textColor} + weight="heavy" + /> + ) : null} + {hasSendButton ? ( + + ) : null} +
) : null} - {traits.length ? ( -
- -
+ {asset.network === AssetTypes.polygon ? ( + // @ts-expect-error JavaScript component + ) : null} - {familyDescription ? ( -
- - {familyDescription} - {familyLink ? ( - } + space={sectionSpace} + > + {(isNFT || isENS) && + asset.network !== AssetTypes.polygon ? ( + + {isNFT && ( + + )} + {isENS && ( + + )} + + ) : null} + {(isNFT || isPoap) && ( + <> + {description ? ( +
- {/* @ts-expect-error JavaScript component */} - {description} +
+ ) : null} + {traits.length ? ( +
+ - +
) : null} -
-
- ) : null} + + )} + {isENS && ( + <> + {profileInfoSectionAvailable && ( +
+ {lang.t( + 'expanded_state.unique_expanded.edit' + )} + + ) + } + paragraphSpace={{ custom: 22 }} + title={`${lang.t( + 'expanded_state.unique_expanded.profile_info' + )}`} + titleEmoji="🤿" + > + +
+ )} +
+ +
+
+ +
+ + )} + {familyDescription ? ( +
+ + {familyDescription} + {familyLink ? ( + + {/* @ts-expect-error JavaScript component */} + + + ) : null} + +
+ ) : null} +
- - - - + + + + @@ -551,7 +690,7 @@ const UniqueTokenExpandedState = ({ )} /> - + ); }; diff --git a/src/components/expanded-state/WalletProfileState.js b/src/components/expanded-state/WalletProfileState.js index 3c0454f82e5..b02625d7d9a 100644 --- a/src/components/expanded-state/WalletProfileState.js +++ b/src/components/expanded-state/WalletProfileState.js @@ -1,78 +1,18 @@ import analytics from '@segment/analytics-react-native'; import lang from 'i18n-js'; -import React, { useCallback, useRef, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { InteractionManager } from 'react-native'; import { useTheme } from '../../context/ThemeContext'; import { getRandomColor } from '../../styles/colors'; -import Divider from '../Divider'; -import { ButtonPressAnimation } from '../animations'; -import { BiometricButtonContent } from '../buttons'; -import ImageAvatar from '../contacts/ImageAvatar'; -import CopyTooltip from '../copy-tooltip'; -import { Centered, ColumnWithDividers } from '../layout'; -import { AvatarCircle } from '../profile'; -import { Text, TruncatedAddress } from '../text'; -import { ProfileModal, ProfileNameInput } from './profile'; +import ProfileModal from './profile/ProfileModal'; import { removeFirstEmojiFromString, returnStringFirstEmoji, } from '@rainbow-me/helpers/emojiHandler'; - -import { useAccountProfile } from '@rainbow-me/hooks'; import { useNavigation } from '@rainbow-me/navigation'; import Routes from '@rainbow-me/routes'; -import styled from '@rainbow-me/styled-components'; -import { margin, padding, position } from '@rainbow-me/styles'; import { profileUtils } from '@rainbow-me/utils'; -const WalletProfileAddressText = styled(TruncatedAddress).attrs( - ({ theme: { colors } }) => ({ - align: 'center', - color: colors.alpha(colors.blueGreyDark, 0.6), - firstSectionLength: 4, - size: 'large', - truncationLength: 4, - weight: 'bold', - }) -)({ - ...margin.object(android ? 0 : 6, 0, android ? 0 : 5), - width: '100%', -}); - -const Spacer = styled.View({ - height: 19, -}); - -const WalletProfileButton = styled(ButtonPressAnimation)({ - ...padding.object(15, 0, 19), - ...position.centeredAsObject, - flexDirection: 'row', - height: 58, - width: '100%', -}); - -const WalletProfileButtonText = styled(Text).attrs({ - align: 'center', - size: 'larger', -})({}); - -const ProfileImage = styled(ImageAvatar)({ - marginBottom: 15, -}); - -const WalletProfileDivider = styled(Divider).attrs(({ theme: { colors } }) => ({ - borderRadius: 1, - color: colors.rowDividerLight, - inset: false, -}))({}); - -const WalletProfileModal = styled(ProfileModal).attrs({ - dividerRenderer: WalletProfileDivider, -})({ - ...padding.object(24, 19, 0), - width: '100%', -}); - export default function WalletProfileState({ actionType, address, @@ -88,8 +28,6 @@ export default function WalletProfileState({ profileUtils.addressHashedEmoji(address); const { goBack, navigate } = useNavigation(); - const { accountImage } = useAccountProfile(); - const { colors } = useTheme(); const indexOfForceColor = colors.avatarBackgrounds.indexOf(forceColor); @@ -102,12 +40,13 @@ export default function WalletProfileState({ : isNewProfile ? null : (indexOfForceColor !== -1 && indexOfForceColor) || getRandomColor(); + const accentColor = colors.avatarBackgrounds[color]; + const [value, setValue] = useState( profile?.name ? removeFirstEmojiFromString(profile.name) : '' ); - const inputRef = useRef(null); - const profileImage = accountImage || profile.image; + const profileImage = profile.image; const handleCancel = useCallback(() => { goBack(); @@ -125,13 +64,14 @@ export default function WalletProfileState({ typeof color === 'string' ? profileUtils.colorHexToIndex(color) : color, + image: profileImage, name: nameEmoji ? `${nameEmoji} ${value}` : value, }); + goBack(); + if (actionType === 'Create' && isNewProfile) { + navigate(Routes.CHANGE_WALLET_SHEET); + } }); - goBack(); - if (actionType === 'Create' && isNewProfile) { - navigate(Routes.CHANGE_WALLET_SHEET); - } }, [ actionType, color, @@ -140,78 +80,30 @@ export default function WalletProfileState({ nameEmoji, navigate, onCloseModal, + profileImage, value, ]); - const handleTriggerFocusInput = useCallback(() => inputRef.current?.focus(), [ - inputRef, - ]); - return ( - - - {profileImage ? ( - - ) : ( - // hide avatar if creating new wallet since we - // don't know what emoji / color it will be (determined by address) - (!isNewProfile || address) && ( - - ) - )} - {isNewProfile && !address && } - - {address && ( - - - - )} - - - - - - - - {lang.t('button.cancel')} - - - - + ); } diff --git a/src/components/expanded-state/custom-gas/GweiInputPill.js b/src/components/expanded-state/custom-gas/GweiInputPill.js index 2ac922055dc..b9632d60539 100644 --- a/src/components/expanded-state/custom-gas/GweiInputPill.js +++ b/src/components/expanded-state/custom-gas/GweiInputPill.js @@ -9,11 +9,13 @@ import { buildTextStyles, margin, padding } from '@rainbow-me/styles'; const ANDROID_EXTRA_LINE_HEIGHT = 6; -const GweiPill = styled(LinearGradient).attrs(({ theme: { colors } }) => ({ - colors: colors.gradients.lightGreyTransparent, - end: { x: 0.5, y: 1 }, - start: { x: 0, y: 0 }, -}))({ +const GweiPill = styled(LinearGradient).attrs( + ({ theme: { colors, isDarkMode } }) => ({ + colors: colors.gradients.lightGreyTransparent, + end: isDarkMode ? { x: 0, y: 0 } : { x: 0.5, y: 1 }, + start: isDarkMode ? { x: 0.5, y: 1 } : { x: 0, y: 0 }, + }) +)({ borderRadius: 15, height: 40, ...(ios ? { height: 40 } : padding.object(10, 12)), diff --git a/src/components/expanded-state/ens/AdvancedSection.tsx b/src/components/expanded-state/ens/AdvancedSection.tsx new file mode 100644 index 00000000000..4ea68a79d0c --- /dev/null +++ b/src/components/expanded-state/ens/AdvancedSection.tsx @@ -0,0 +1,32 @@ +import lang from 'i18n-js'; +import { upperFirst } from 'lodash'; +import React from 'react'; +import InfoRow, { InfoRowSkeleton } from './InfoRow'; +import { Stack } from '@rainbow-me/design-system'; + +export default function AdvancedSection({ + isLoading, + resolver, +}: { + isLoading?: boolean; + resolver?: { address?: string; type?: string }; +}) { + return ( + + {isLoading ? ( + + ) : ( + <> + {resolver && ( + + )} + + )} + + ); +} diff --git a/src/components/expanded-state/ens/ConfigurationSection.tsx b/src/components/expanded-state/ens/ConfigurationSection.tsx new file mode 100644 index 00000000000..e1a332392dd --- /dev/null +++ b/src/components/expanded-state/ens/ConfigurationSection.tsx @@ -0,0 +1,93 @@ +import { useNavigation } from '@react-navigation/core'; +import lang from 'i18n-js'; +import React from 'react'; +import { ENSConfirmUpdateSheetHeight } from '../../../screens/ENSConfirmRegisterSheet'; +import InfoRow, { InfoRowSkeleton } from './InfoRow'; +import { Stack } from '@rainbow-me/design-system'; +import { REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; +import { useENSRegistration } from '@rainbow-me/hooks'; +import Routes from '@rainbow-me/routes'; +import { formatAddressForDisplay } from '@rainbow-me/utils/abbreviations'; + +export default function ConfigurationSection({ + isLoading, + owner, + registrant, + isOwner, + isPrimary, + isExternal, + isSetNameEnabled, + isReadOnlyWallet, + name, + externalAvatarUrl, +}: { + isLoading?: boolean; + owner?: { name?: string; address?: string }; + registrant?: { name?: string; address?: string }; + isExternal?: boolean; + isOwner?: boolean; + isPrimary?: boolean; + isSetNameEnabled?: boolean; + isReadOnlyWallet?: boolean; + name: string; + externalAvatarUrl?: string | null; +}) { + const { startRegistration } = useENSRegistration(); + const { navigate } = useNavigation(); + + return ( + + {isLoading ? ( + <> + + + + ) : ( + <> + {!isReadOnlyWallet && !isExternal && isSetNameEnabled && ( + { + startRegistration(name, REGISTRATION_MODES.SET_NAME); + navigate(Routes.ENS_CONFIRM_REGISTER_SHEET, { + externalAvatarUrl, + longFormHeight: ENSConfirmUpdateSheetHeight, + mode: REGISTRATION_MODES.SET_NAME, + name, + }); + }} + switchDisabled={!isOwner} + switchValue={isPrimary} + useAccentColor + /> + )} + {registrant && ( + + )} + {owner && ( + + )} + + )} + + ); +} diff --git a/src/components/expanded-state/ens/InfoRow.tsx b/src/components/expanded-state/ens/InfoRow.tsx new file mode 100644 index 00000000000..d44cdd0ff34 --- /dev/null +++ b/src/components/expanded-state/ens/InfoRow.tsx @@ -0,0 +1,186 @@ +import React, { useCallback, useState } from 'react'; +import { Switch } from 'react-native-gesture-handler'; +import { useTheme } from '../../../context/ThemeContext'; +import { useNavigation } from '../../../navigation/Navigation'; +import { ShimmerAnimation } from '../../animations'; +import ButtonPressAnimation from '../../animations/ButtonPressAnimation'; +import { Icon } from '../../icons'; +import { ImagePreviewOverlayTarget } from '../../images/ImagePreviewOverlay'; +import { + Bleed, + Box, + Inline, + Inset, + Space, + Text, + useForegroundColor, +} from '@rainbow-me/design-system'; +import { ImgixImage } from '@rainbow-me/images'; +import Routes from '@rainbow-me/routes'; + +export function InfoRowSkeleton() { + const { colors } = useTheme(); + return ( + + + + + + + + + ); +} + +export default function InfoRow({ + explainSheetType, + icon = undefined, + isImage = false, + label, + wrapValue = children => children, + value = undefined, + switchValue, + switchDisabled, + useAccentColor, + onSwitchChange, +}: { + explainSheetType?: string; + icon?: string; + isImage?: boolean; + label: string; + wrapValue?: (children: React.ReactNode) => React.ReactNode; + value?: string; + switchValue?: boolean; + switchDisabled?: boolean; + useAccentColor?: boolean; + onSwitchChange?: () => void; +}) { + const { colors } = useTheme(); + const accentColor = useForegroundColor('accent'); + + const [show, setShow] = useState(isImage); + const [isMultiline, setIsMultiline] = useState(false); + const isSwitch = switchValue !== undefined; + + const { navigate } = useNavigation(); + const handlePressExplain = useCallback(() => { + navigate(Routes.EXPLAIN_SHEET, { type: explainSheetType }); + }, [explainSheetType, navigate]); + + return ( + + + + + + {label} + + {explainSheetType && ( + + + 􀅵 + + + )} + + + + {wrapValue( + isImage ? ( + <> + {value && ( + + + + )} + + ) : ( + { + setIsMultiline(height > 40); + setShow(true); + }} + padding={ + (isSwitch ? '0px' : isMultiline ? '15px' : '10px') as Space + } + style={{ + backgroundColor: isSwitch + ? 'transparent' + : useAccentColor + ? accentColor + '10' + : 'rgba(255, 255, 255, 0.08)', + opacity: show ? 1 : 0, + }} + > + + {icon && ( + + + + )} + {value && ( + + {value} + + )} + {isSwitch && ( + + )} + + + ) + )} + + ); +} diff --git a/src/components/expanded-state/ens/ProfileInfoSection.tsx b/src/components/expanded-state/ens/ProfileInfoSection.tsx new file mode 100644 index 00000000000..2903234bec2 --- /dev/null +++ b/src/components/expanded-state/ens/ProfileInfoSection.tsx @@ -0,0 +1,164 @@ +import { partition } from 'lodash'; +import React, { useMemo } from 'react'; +import ButtonPressAnimation from '../../animations/ButtonPressAnimation'; +import InfoRow, { InfoRowSkeleton } from './InfoRow'; +import { Stack } from '@rainbow-me/design-system'; +import { Records } from '@rainbow-me/entities'; +import { ENS_RECORDS } from '@rainbow-me/helpers/ens'; +import { useENSRecordDisplayProperties } from '@rainbow-me/hooks'; + +const omitRecordKeys = [ENS_RECORDS.avatar]; +const topRecordKeys = [ENS_RECORDS.cover, ENS_RECORDS.description]; + +const imageKeyMap = { + [ENS_RECORDS.avatar]: 'avatarUrl', + [ENS_RECORDS.cover]: 'coverUrl', +} as { + [key: string]: 'avatarUrl' | 'coverUrl'; +}; + +export default function ProfileInfoSection({ + allowEdit, + coinAddresses: coinAddressMap, + ensName, + images, + isLoading, + records, +}: { + allowEdit?: boolean; + coinAddresses?: { [key: string]: string }; + ensName?: string; + images?: { + avatarUrl?: string | null; + coverUrl?: string | null; + }; + isLoading?: boolean; + records?: Partial; +}) { + const recordsArray = useMemo( + () => + Object.entries(records || {}) + .filter(([key]) => !omitRecordKeys.includes(key as ENS_RECORDS)) + .map(([key, value]) => + images?.[imageKeyMap[key]] + ? [key, images[imageKeyMap[key]] as string] + : [key, value] + ), + [images, records] + ); + + const [topRecords, otherRecords] = useMemo(() => { + const [topRecords, otherRecords] = partition( + recordsArray, + ([key]: [ENS_RECORDS]) => topRecordKeys.includes(key) + ); + const orderedTopRecords = topRecordKeys + .map(key => topRecords.find(([k]: any) => k === key)) + .filter(Boolean) as [ENS_RECORDS, string][]; + return [orderedTopRecords, otherRecords as [ENS_RECORDS, string][]]; + }, [recordsArray]); + const coinAddresses = useMemo(() => Object.entries(coinAddressMap || {}), [ + coinAddressMap, + ]); + + return ( + + {isLoading ? ( + <> + + + + + + ) : ( + <> + {topRecords.map(([recordKey, recordValue]) => + recordValue ? ( + + ) : null + )} + {coinAddresses.map(([recordKey, recordValue]) => + recordValue ? ( + + ) : null + )} + {otherRecords.map(([recordKey, recordValue]) => + recordValue ? ( + + ) : null + )} + + )} + + ); +} + +function ProfileInfoRow({ + allowEdit, + ensName, + recordKey, + recordValue, + type, +}: { + allowEdit?: boolean; + ensName?: string; + recordKey: string; + recordValue: string; + type: 'address' | 'record'; +}) { + const { + ContextMenuButton, + icon, + isImageValue, + label, + url, + value, + } = useENSRecordDisplayProperties({ + allowEdit, + ensName, + key: recordKey, + type, + value: recordValue, + }); + + return ( + + !isImageValue ? ( + + + {children} + + + ) : ( + children + ) + } + /> + ); +} diff --git a/src/components/expanded-state/profile/ProfileAvatarButton.js b/src/components/expanded-state/profile/ProfileAvatarButton.js deleted file mode 100644 index 99d6e5b7c4a..00000000000 --- a/src/components/expanded-state/profile/ProfileAvatarButton.js +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import { ButtonPressAnimation } from '../../animations'; -import { ContactAvatar } from '../../contacts'; - -const ProfileAvatarButton = ({ - changeAvatar, - color, - marginBottom = 15, - testID, - value, - radiusAndroid, -}) => { - return ( - - - - ); -}; - -export default React.memo(ProfileAvatarButton); diff --git a/src/components/expanded-state/profile/ProfileModal.tsx b/src/components/expanded-state/profile/ProfileModal.tsx new file mode 100644 index 00000000000..14d6f61d3da --- /dev/null +++ b/src/components/expanded-state/profile/ProfileModal.tsx @@ -0,0 +1,170 @@ +import lang from 'i18n-js'; +import React, { useCallback, useRef } from 'react'; +import { View } from 'react-native'; +import Divider from '../../Divider'; +import { ButtonPressAnimation } from '../../animations'; +import { BiometricButtonContent } from '../../buttons'; +import CopyTooltip from '../../copy-tooltip'; +import { Centered, ColumnWithDividers } from '../../layout'; +import { AvatarCircle } from '../../profile'; +import { Text, TruncatedAddress } from '../../text'; +import ProfileModalContainer from './ProfileModalContainer'; +import ProfileNameInput from './ProfileNameInput'; +import { useTheme } from '@rainbow-me/context'; +import styled from '@rainbow-me/styled-components'; +import { margin, padding, position } from '@rainbow-me/styles'; + +const ProfileAddressText = styled(TruncatedAddress).attrs( + ({ theme: { colors } }: any) => ({ + align: 'center', + color: colors.alpha(colors.blueGreyDark, 0.6), + firstSectionLength: 4, + size: 'large', + truncationLength: 4, + weight: 'bold', + }) +)({ + ...margin.object(android ? 0 : 6, 0, android ? 0 : 5), + width: '100%', +}); + +const Spacer = styled(View)({ + height: 19, +}); + +const ProfileButton = styled(ButtonPressAnimation)({ + ...padding.object(15, 0, 19), + ...position.centeredAsObject, + flexDirection: 'row', + height: 58, + width: '100%', +}); + +const ProfileButtonText = styled(Text).attrs({ + align: 'center', + size: 'larger', +})({}); + +const ProfileDivider = styled(Divider).attrs(({ theme: { colors } }: any) => ({ + borderRadius: 1, + color: colors.rowDividerLight, + inset: false, +}))({}); + +const Container = styled(ProfileModalContainer).attrs({ + dividerRenderer: ProfileDivider, +})({ + ...padding.object(24, 19, 0), + width: '100%', +}); + +type ProfileModalProps = { + address: string; + imageAvatar: string; + emojiAvatar: string; + accentColor: string; + toggleSubmitButtonIcon: boolean; + toggleAvatar: boolean; + handleSubmit: () => void; + onChange: (value: string) => void; + inputValue: string; + handleCancel: () => void; + submitButtonText: string; + placeholder: string; +}; + +const ProfileModal = ({ + address, + imageAvatar, + emojiAvatar, + accentColor, + toggleSubmitButtonIcon, + toggleAvatar, + handleSubmit, + onChange, + inputValue, + handleCancel, + submitButtonText, + placeholder, +}: ProfileModalProps) => { + const { colors, isDarkMode } = useTheme(); + const inputRef = useRef(null); + + const handleTriggerFocusInput = useCallback(() => inputRef.current?.focus(), [ + inputRef, + ]); + + return ( + + + {toggleAvatar && + (imageAvatar ? ( + + ) : ( + + ))} + {!toggleAvatar && } + + {address && ( + + + + )} + + + + + + + + {lang.t('button.cancel')} + + + + + ); +}; + +export default ProfileModal; diff --git a/src/components/expanded-state/profile/ProfileModal.js b/src/components/expanded-state/profile/ProfileModalContainer.js similarity index 91% rename from src/components/expanded-state/profile/ProfileModal.js rename to src/components/expanded-state/profile/ProfileModalContainer.js index b1247ef9047..8c64aca4db2 100644 --- a/src/components/expanded-state/profile/ProfileModal.js +++ b/src/components/expanded-state/profile/ProfileModalContainer.js @@ -6,7 +6,7 @@ import { AssetPanel, FloatingPanels } from '../../floating-panels'; import { KeyboardFixedOpenLayout } from '../../layout'; import { useDimensions } from '@rainbow-me/hooks'; -export default function ProfileModal({ onPressBackdrop, ...props }) { +export default function ProfileModalContainer({ onPressBackdrop, ...props }) { const { width: deviceWidth } = useDimensions(); const { params } = useRoute(); diff --git a/src/components/expanded-state/profile/index.js b/src/components/expanded-state/profile/index.js deleted file mode 100644 index df4e1aac63a..00000000000 --- a/src/components/expanded-state/profile/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { default as ProfileAvatarButton } from './ProfileAvatarButton'; -export { default as ProfileModal } from './ProfileModal'; -export { default as ProfileNameInput } from './ProfileNameInput'; diff --git a/src/components/expanded-state/unique-token/ENSBriefTokenInfoRow.tsx b/src/components/expanded-state/unique-token/ENSBriefTokenInfoRow.tsx new file mode 100644 index 00000000000..b391b150c19 --- /dev/null +++ b/src/components/expanded-state/unique-token/ENSBriefTokenInfoRow.tsx @@ -0,0 +1,126 @@ +import { useNavigation } from '@react-navigation/core'; +import { format, formatDistanceStrict } from 'date-fns'; +import lang from 'i18n-js'; +import React, { useCallback, useState } from 'react'; +import { InteractionManager } from 'react-native'; +import { ENSConfirmRenewSheetHeight } from '../../../screens/ENSConfirmRegisterSheet'; +import { ButtonPressAnimation } from '../../animations'; +import { TokenInfoItem, TokenInfoValue } from '../../token-info'; +import { useTheme } from '@rainbow-me/context'; +import { Column, Columns, Inset } from '@rainbow-me/design-system'; +import { REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; +import { useENSProfile, useENSRegistration } from '@rainbow-me/hooks'; +import Routes from '@rainbow-me/routes'; + +export default function ENSBriefTokenInfoRow({ + color, + expiryDate, + registrationDate, + showExtendDuration, + ensName, + externalAvatarUrl, +}: { + color?: string; + expiryDate?: number; + registrationDate?: number; + ensName: string; + showExtendDuration?: boolean; + externalAvatarUrl?: string | null; +}) { + const { colors } = useTheme(); + const { navigate } = useNavigation(); + const { startRegistration } = useENSRegistration(); + const { data } = useENSProfile(ensName); + const [showExpiryDistance, setShowExpiryDistance] = useState(true); + const handlePressExpiryDate = useCallback(() => { + setShowExpiryDistance(x => !x); + }, []); + + const handlePressEditExpiryDate = useCallback(() => { + InteractionManager.runAfterInteractions(() => { + const cleanENSName = ensName?.split(' ')?.[0] ?? ensName; + startRegistration(cleanENSName, REGISTRATION_MODES.RENEW); + navigate(Routes.ENS_CONFIRM_REGISTER_SHEET, { + ensName: cleanENSName, + externalAvatarUrl, + longFormHeight: + ENSConfirmRenewSheetHeight + (data?.images?.avatarUrl ? 70 : 0), + mode: REGISTRATION_MODES.RENEW, + }); + }); + }, [ + ensName, + startRegistration, + navigate, + externalAvatarUrl, + data?.images?.avatarUrl, + ]); + + return ( + + {/* @ts-expect-error JavaScript component */} + + {registrationDate + ? format(new Date(registrationDate * 1000), 'MMM d, yyyy') + : ''} + + {/* @ts-expect-error JavaScript component */} + + + + + 􀌆 + + + +
+ ) + } + align="right" + color={colors.whiteLabel} + enableHapticFeedback + isENS + isNft + loading={!expiryDate} + onPress={handlePressExpiryDate} + size="larger" + title={lang.t( + `expanded_state.unique_expanded.${ + showExpiryDistance ? 'expires_in' : 'expires_on' + }` + )} + weight="heavy" + > + {expiryDate + ? showExpiryDistance + ? formatDistanceStrict(new Date(), new Date(expiryDate * 1000)) + : format(new Date(expiryDate * 1000), 'MMM d, yyyy') + : ''} + + + ); +} diff --git a/src/components/expanded-state/unique-token/NFTBriefTokenInfoRow.tsx b/src/components/expanded-state/unique-token/NFTBriefTokenInfoRow.tsx new file mode 100644 index 00000000000..93a11ff6b19 --- /dev/null +++ b/src/components/expanded-state/unique-token/NFTBriefTokenInfoRow.tsx @@ -0,0 +1,126 @@ +import lang from 'i18n-js'; +import React, { useCallback, useEffect, useState } from 'react'; + +import assetTypes from '../../../entities/assetTypes'; +import { TokenInfoItem } from '../../token-info'; +import { useTheme } from '@rainbow-me/context'; +import { Columns } from '@rainbow-me/design-system'; +import { apiGetUniqueTokenFloorPrice } from '@rainbow-me/handlers/opensea-api'; +import { useAccountSettings } from '@rainbow-me/hooks'; +import { useNavigation } from '@rainbow-me/navigation'; +import Routes from '@rainbow-me/routes'; +import { convertAmountToNativeDisplay } from '@rainbow-me/utilities'; +import { ethereumUtils } from '@rainbow-me/utils'; + +export default function NFTBriefTokenInfoRow({ + currentPrice, + lastPrice, + lastSalePaymentToken, + network: assetNetwork, + urlSuffixForAsset, +}: { + currentPrice?: number | null; + lastPrice?: number | null; + lastSalePaymentToken?: string | null; + network?: string; + urlSuffixForAsset: string; +}) { + const { colors } = useTheme(); + + const { navigate } = useNavigation(); + + const { nativeCurrency, network } = useAccountSettings(); + + const [floorPrice, setFloorPrice] = useState(null); + useEffect(() => { + assetNetwork !== assetTypes.polygon && + apiGetUniqueTokenFloorPrice(network, urlSuffixForAsset).then(result => { + setFloorPrice(result); + }); + }, [assetNetwork, network, urlSuffixForAsset]); + + const [showCurrentPriceInEth, setShowCurrentPriceInEth] = useState(true); + const toggleCurrentPriceDisplayCurrency = useCallback( + () => setShowCurrentPriceInEth(!showCurrentPriceInEth), + [showCurrentPriceInEth, setShowCurrentPriceInEth] + ); + + const [showFloorInEth, setShowFloorInEth] = useState(true); + const toggleFloorDisplayCurrency = useCallback( + () => setShowFloorInEth(!showFloorInEth), + [showFloorInEth, setShowFloorInEth] + ); + + const handlePressCollectionFloor = useCallback(() => { + navigate(Routes.EXPLAIN_SHEET, { + type: 'floor_price', + }); + }, [navigate]); + + const lastSalePrice = + lastPrice != null + ? lastPrice === 0 + ? `< 0.001 ${lastSalePaymentToken}` + : `${lastPrice} ${lastSalePaymentToken}` + : 'None'; + const priceOfEth = ethereumUtils.getEthPriceUnit() as number; + + return ( + + {/* @ts-expect-error JavaScript component */} + + {showCurrentPriceInEth || nativeCurrency === 'ETH' || !currentPrice + ? currentPrice || lastSalePrice + : convertAmountToNativeDisplay( + // @ts-expect-error currentPrice is a number? + parseFloat(currentPrice) * priceOfEth, + nativeCurrency + )} + + {/* @ts-expect-error JavaScript component */} + + {showFloorInEth || + nativeCurrency === 'ETH' || + floorPrice === 'None' || + floorPrice === null + ? floorPrice + : convertAmountToNativeDisplay( + parseFloat(floorPrice) * priceOfEth, + nativeCurrency + )} + + + ); +} diff --git a/src/components/expanded-state/unique-token/UniqueTokenExpandedStateContent.js b/src/components/expanded-state/unique-token/UniqueTokenExpandedStateContent.js index 8b812b47832..fa713bb4e64 100644 --- a/src/components/expanded-state/unique-token/UniqueTokenExpandedStateContent.js +++ b/src/components/expanded-state/unique-token/UniqueTokenExpandedStateContent.js @@ -1,7 +1,5 @@ -import { toLower } from 'lodash'; import React, { useMemo } from 'react'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; -import { ENS_NFT_CONTRACT_ADDRESS } from '../../../references'; import { magicMemo } from '../../../utils'; import { SimpleModelView } from '../../3d'; import { AudioPlayer } from '../../audio'; @@ -32,12 +30,11 @@ const UniqueTokenExpandedStateContent = ({ resizeMode = 'cover', textColor, disablePreview, + opacity, yPosition, onContentFocus, onContentBlur, }) => { - const isENS = - toLower(asset.asset_contract.address) === toLower(ENS_NFT_CONTRACT_ADDRESS); const url = useMemo(() => { if (asset.isPoap) return asset.animation_url; return asset.image_url; @@ -64,9 +61,9 @@ const UniqueTokenExpandedStateContent = ({ (ios ? supportsVideo : supportsAnythingExceptImageAnd3d) } horizontalPadding={horizontalPadding} - isENS={isENS} onZoomIn={onContentFocus} onZoomOut={onContentBlur} + opacity={opacity} yDisplacement={yPosition} > diff --git a/src/components/expanded-state/unique-token/UniqueTokenExpandedStateHeader.tsx b/src/components/expanded-state/unique-token/UniqueTokenExpandedStateHeader.tsx index 7f4c4c3d477..ec1e857fcbe 100644 --- a/src/components/expanded-state/unique-token/UniqueTokenExpandedStateHeader.tsx +++ b/src/components/expanded-state/unique-token/UniqueTokenExpandedStateHeader.tsx @@ -2,7 +2,6 @@ import lang from 'i18n-js'; import { startCase, toLower } from 'lodash'; import React, { useCallback, useMemo } from 'react'; import { Linking, View } from 'react-native'; -// @ts-expect-error Missing types import { ContextMenuButton } from 'react-native-ios-context-menu'; import URL from 'url-parse'; import { buildUniqueTokenName } from '../../../helpers/assets'; @@ -21,7 +20,6 @@ import { } from '@rainbow-me/design-system'; import { UniqueAsset } from '@rainbow-me/entities'; import { Network } from '@rainbow-me/helpers'; -import isSupportedUriExtension from '@rainbow-me/helpers/isSupportedUriExtension'; import { useAccountProfile, useClipboard, @@ -38,6 +36,7 @@ import { showActionSheetWithOptions, } from '@rainbow-me/utils'; import { getFullResUrl } from '@rainbow-me/utils/getFullResUrl'; +import isSVGImage from '@rainbow-me/utils/isSVG'; const AssetActionsEnum = { copyTokenID: 'copyTokenID', @@ -172,26 +171,49 @@ const UniqueTokenExpandedStateHeader = ({ const familyMenuConfig = useMemo(() => { return { menuItems: [ - !asset?.isPoap && { - ...FamilyActions[FamilyActionsEnum.viewCollection], - }, - (asset.external_link || asset.collection.external_url) && { - ...FamilyActions[FamilyActionsEnum.collectionWebsite], - discoverabilityTitle: formattedCollectionUrl, - }, - asset.collection.twitter_username && { - ...FamilyActions[FamilyActionsEnum.twitter], - }, - asset.collection.discord_url && { - ...FamilyActions[FamilyActionsEnum.discord], - }, + ...(!asset?.isPoap + ? [ + { + ...FamilyActions[FamilyActionsEnum.viewCollection], + }, + ] + : []), + ...(asset.external_link || asset.collection.external_url + ? [ + { + ...FamilyActions[FamilyActionsEnum.collectionWebsite], + discoverabilityTitle: formattedCollectionUrl, + }, + ] + : []), + ...(asset.collection.twitter_username + ? [ + { + ...FamilyActions[FamilyActionsEnum.twitter], + }, + ] + : []), + ...(asset.collection.discord_url + ? [ + { + ...FamilyActions[FamilyActionsEnum.discord], + }, + ] + : []), ], menuTitle: '', - } as const; - }, [asset, formattedCollectionUrl]); + }; + }, [ + asset.collection.discord_url, + asset.collection.external_url, + asset.collection.twitter_username, + asset.external_link, + asset?.isPoap, + formattedCollectionUrl, + ]); // @ts-expect-error image_url could be null or undefined? - const isSVG = isSupportedUriExtension(asset.image_original_url, ['.svg']); + const isSVG = isSVGImage(asset.image_url); const isENS = toLower(asset.asset_contract.address) === toLower(ENS_NFT_CONTRACT_ADDRESS); @@ -222,7 +244,7 @@ const UniqueTokenExpandedStateHeader = ({ }, ], menuTitle: '', - } as const; + }; }, [asset.id, asset?.network, isPhotoDownloadAvailable]); const handlePressFamilyMenuItem = useCallback( @@ -396,19 +418,17 @@ const UniqueTokenExpandedStateHeader = ({ return ( - + {buildUniqueTokenName(asset)} @@ -424,13 +444,11 @@ const UniqueTokenExpandedStateHeader = ({ diff --git a/src/components/expanded-state/unique-token/ZoomableWrapper.android.js b/src/components/expanded-state/unique-token/ZoomableWrapper.android.js index 3e997fc790a..b2849ebc807 100644 --- a/src/components/expanded-state/unique-token/ZoomableWrapper.android.js +++ b/src/components/expanded-state/unique-token/ZoomableWrapper.android.js @@ -36,10 +36,10 @@ const exitConfig = { stiffness: 800, }; const GestureBlocker = styled(View)({ - height: ({ height }) => 2 * height, - left: ({ containerWidth, width }) => -(width - containerWidth) / 2, + height: ({ height }) => height * 3, + left: ({ xOffset }) => -xOffset, position: 'absolute', - top: ({ height }) => -height, + top: ({ yOffset }) => -yOffset * 3, width: ({ width }) => width, }); @@ -73,9 +73,16 @@ export const ZoomableWrapper = ({ aspectRatio, borderRadius, disableAnimations, + onZoomInWorklet, + onZoomOutWorklet, + opacity, + yOffset = 85, + xOffset: givenXOffset = 0, onZoomIn, onZoomOut, yDisplacement: givenYDisplacement, + width, + height, }) => { // eslint-disable-next-line react-hooks/rules-of-hooks const animationProgress = givenAnimationProgress || useSharedValue(0); @@ -84,8 +91,8 @@ export const ZoomableWrapper = ({ const { height: deviceHeight, width: deviceWidth } = useDimensions(); - const maxImageWidth = deviceWidth - horizontalPadding * 2; - const maxImageHeight = deviceHeight / 2; + const maxImageWidth = width || deviceWidth - horizontalPadding * 2; + const maxImageHeight = height || deviceHeight / 2; const [ containerWidth = maxImageWidth, containerHeight = maxImageWidth, @@ -128,13 +135,22 @@ export const ZoomableWrapper = ({ StatusBar.setHidden(false); onZoomOut?.(); } - }, [isZoomed]); + }, [isZoomed, onZoomIn, onZoomOut]); const fullSizeHeight = Math.min(deviceHeight, deviceWidth / aspectRatio); const fullSizeWidth = Math.min(deviceWidth, deviceHeight * aspectRatio); - const containerStyle = useAnimatedStyle( - () => ({ + const xOffset = givenXOffset || (width - containerWidth) / 2 || 0; + + const containerStyle = useAnimatedStyle(() => { + const scale = + 1 + + animationProgress.value * + (fullSizeHeight / (containerHeightValue.value ?? 1) - 1); + + const maxWidth = (deviceWidth - containerWidth) / 2; + return { + opacity: opacity?.value ?? 1, transform: [ { translateY: @@ -147,16 +163,20 @@ export const ZoomableWrapper = ({ (fullSizeHeight - containerHeightValue.value)) / 2, }, + ...(givenXOffset + ? [ + { + translateX: + -animationProgress.value * (-maxWidth + givenXOffset), + }, + ] + : []), { - scale: - 1 + - animationProgress.value * - (fullSizeHeight / (containerHeightValue.value ?? 1) - 1), + scale, }, ], - }), - [fullSizeHeight, fullSizeWidth] - ); + }; + }, [fullSizeHeight, fullSizeWidth]); const cornerStyle = useAnimatedStyle(() => ({ borderRadius: (1 - animationProgress.value) * (borderRadius ?? 16), @@ -251,6 +271,7 @@ export const ZoomableWrapper = ({ if (ctx.startScale <= MIN_IMAGE_SCALE && !ctx.blockExitZoom) { isZoomedValue.value = false; runOnJS(setIsZoomed)(false); + onZoomOutWorklet?.(); animationProgress.value = withSpring(0, exitConfig); scale.value = withSpring(MIN_IMAGE_SCALE, exitConfig); translateX.value = withSpring(0, exitConfig); @@ -275,6 +296,7 @@ export const ZoomableWrapper = ({ ) { isZoomedValue.value = false; runOnJS(setIsZoomed)(false); + onZoomOutWorklet?.(); scale.value = withSpring(MIN_IMAGE_SCALE, exitConfig); animationProgress.value = withSpring(0, exitConfig); @@ -342,7 +364,7 @@ export const ZoomableWrapper = ({ if (state.value === 1) { return; } - const zooming = Math.pow(fullSizeHeight / containerHeightValue.value, 2); + const zooming = fullSizeHeight / containerHeightValue.value; if (event.numberOfPointers === 2) { ctx.numberOfPointers = 2; } @@ -431,6 +453,7 @@ export const ZoomableWrapper = ({ if (!isZoomedValue.value) { isZoomedValue.value = true; runOnJS(setIsZoomed)(true); + onZoomInWorklet?.(); animationProgress.value = withSpring(1, enterConfig); } else if ( scale.value === MIN_IMAGE_SCALE && @@ -443,6 +466,7 @@ export const ZoomableWrapper = ({ // dismiss if tap was outside image bounds isZoomedValue.value = false; runOnJS(setIsZoomed)(false); + onZoomOutWorklet?.(); animationProgress.value = withSpring(0, exitConfig); } }, @@ -493,11 +517,11 @@ export const ZoomableWrapper = ({ diff --git a/src/components/expanded-state/unique-token/ZoomableWrapper.js b/src/components/expanded-state/unique-token/ZoomableWrapper.js index a377cb1eb88..21583a1bff7 100644 --- a/src/components/expanded-state/unique-token/ZoomableWrapper.js +++ b/src/components/expanded-state/unique-token/ZoomableWrapper.js @@ -21,6 +21,7 @@ import { ButtonPressAnimation } from '../../animations'; import { useDimensions } from '@rainbow-me/hooks'; import styled from '@rainbow-me/styled-components'; import { position } from '@rainbow-me/styles'; +import { safeAreaInsetValues } from '@rainbow-me/utils'; const adjustConfig = { duration: 300, @@ -37,20 +38,26 @@ const exitConfig = { stiffness: 800, }; const GestureBlocker = styled(View)({ - height: ({ height }) => height, - left: ({ containerWidth, width }) => -(width - containerWidth) / 2, + height: ({ height }) => height * 3, + left: ({ containerWidth, width, xOffset }) => + -(xOffset || (width - containerWidth) / 2), position: 'absolute', - top: -85, + top: ({ height }) => -height, width: ({ width }) => width, }); // TODO osdnk const Container = vstyled(Animated.View)` align-self: center; - shadow-color: ${({ theme: { colors } }) => colors.shadowBlack}; - shadow-offset: 0 20px; - shadow-opacity: 0.4; - shadow-radius: 30px; + ${({ hasShadow, theme: { colors } }) => + hasShadow + ? ` + shadow-color: ${colors.shadowBlack}; + shadow-offset: 0 20px; + shadow-opacity: 0.4; + shadow-radius: 30px; + ` + : ''} `; const ImageWrapper = styled(Animated.View)({ @@ -69,15 +76,25 @@ const MIN_IMAGE_SCALE = 1; const THRESHOLD = 250; export const ZoomableWrapper = ({ - animationProgress: givenAnimationProgress, + animationProgress: givenAnimationProgress = undefined, children, + hasShadow = true, horizontalPadding, aspectRatio, borderRadius, disableAnimations, - onZoomIn, - onZoomOut, + disableEnteringWithPinch, + hideStatusBar = true, + onZoomIn = () => {}, + onZoomInWorklet = () => {}, + onZoomOut = () => {}, + onZoomOutWorklet = () => {}, + opacity, + yOffset = 85, + xOffset: givenXOffset = 0, yDisplacement: givenYDisplacement, + width, + height, }) => { // eslint-disable-next-line react-hooks/rules-of-hooks const animationProgress = givenAnimationProgress || useSharedValue(0); @@ -86,8 +103,14 @@ export const ZoomableWrapper = ({ const { height: deviceHeight, width: deviceWidth } = useDimensions(); - const maxImageWidth = deviceWidth - horizontalPadding * 2; - const maxImageHeight = deviceHeight / 2; + let deviceHeightWithMaybeHiddenStatusBar = deviceHeight; + if (!hideStatusBar) { + deviceHeightWithMaybeHiddenStatusBar = + deviceHeight - safeAreaInsetValues.top; + } + + const maxImageWidth = width || deviceWidth - horizontalPadding * 2; + const maxImageHeight = height || deviceHeightWithMaybeHiddenStatusBar / 2; const [ containerWidth = maxImageWidth, containerHeight = maxImageWidth, @@ -122,27 +145,52 @@ export const ZoomableWrapper = ({ const [isZoomed, setIsZoomed] = useState(false); const isZoomedValue = useSharedValue(false); + useEffect(() => { + if (hideStatusBar) { + if (isZoomed) { + StatusBar.setHidden(true); + } else { + StatusBar.setHidden(false); + } + } + }, [hideStatusBar, isZoomed]); + useEffect(() => { if (isZoomed) { - StatusBar.setHidden(true); onZoomIn?.(); } else { - StatusBar.setHidden(false); onZoomOut?.(); } }, [isZoomed, onZoomIn, onZoomOut]); - const fullSizeHeight = Math.min(deviceHeight, deviceWidth / aspectRatio); - const fullSizeWidth = Math.min(deviceWidth, deviceHeight * aspectRatio); + const fullSizeHeight = Math.min( + deviceHeightWithMaybeHiddenStatusBar, + deviceWidth / aspectRatio + ); + const fullSizeWidth = Math.min( + deviceWidth, + deviceHeightWithMaybeHiddenStatusBar * aspectRatio + ); const zooming = fullSizeHeight / containerHeightValue.value; - const containerStyle = useAnimatedStyle( - () => ({ + const xOffset = givenXOffset || (width - containerWidth) / 2 || 0; + + const containerStyle = useAnimatedStyle(() => { + const scale = + 1 + + animationProgress.value * + (fullSizeHeight / (containerHeightValue.value ?? 1) - 1); + + const maxWidth = (deviceWidth - containerWidth) / 2; + return { + opacity: opacity?.value ?? 1, transform: [ { translateY: animationProgress.value * - (yDisplacement.value + (deviceHeight - fullSizeHeight) / 2 - 85), + (yDisplacement.value + + (deviceHeightWithMaybeHiddenStatusBar - fullSizeHeight) / 2 - + (hideStatusBar ? 85 : 68)), }, { translateY: @@ -150,16 +198,20 @@ export const ZoomableWrapper = ({ (fullSizeHeight - containerHeightValue.value)) / 2, }, + ...(givenXOffset + ? [ + { + translateX: + -animationProgress.value * (-maxWidth + givenXOffset), + }, + ] + : []), { - scale: - 1 + - animationProgress.value * - (fullSizeHeight / (containerHeightValue.value ?? 1) - 1), + scale, }, ], - }), - [fullSizeHeight, fullSizeWidth] - ); + }; + }, [fullSizeHeight, fullSizeWidth, hideStatusBar]); const cornerStyle = useAnimatedStyle(() => ({ borderRadius: (1 - animationProgress.value) * (borderRadius ?? 16), @@ -185,10 +237,10 @@ export const ZoomableWrapper = ({ // determine whether to snap to screen edges let breakingScaleX = deviceWidth / fullSizeWidth; - let breakingScaleY = deviceHeight / fullSizeHeight; + let breakingScaleY = deviceHeightWithMaybeHiddenStatusBar / fullSizeHeight; if (isZoomedValue.value === false) { breakingScaleX = deviceWidth / containerWidth; - breakingScaleY = deviceHeight / containerHeight; + breakingScaleY = deviceHeightWithMaybeHiddenStatusBar / containerHeight; } const zooming = fullSizeHeight / containerHeightValue.value; @@ -197,7 +249,8 @@ export const ZoomableWrapper = ({ 2 / zooming; const maxDisplacementY = - (deviceHeight * (Math.max(1, targetScale / breakingScaleY) - 1)) / + (deviceHeightWithMaybeHiddenStatusBar * + (Math.max(1, targetScale / breakingScaleY) - 1)) / 2 / zooming; @@ -258,6 +311,7 @@ export const ZoomableWrapper = ({ const adjustedScale = scale.value / (fullSizeWidth / containerWidth); isZoomedValue.value = true; runOnJS(setIsZoomed)(true); + onZoomInWorklet?.(); animationProgress.value = withTiming(1, adjustConfig); scale.value = withTiming(adjustedScale, adjustConfig); } else { @@ -271,6 +325,7 @@ export const ZoomableWrapper = ({ if (ctx.startScale <= MIN_IMAGE_SCALE && !ctx.blockExitZoom) { isZoomedValue.value = false; runOnJS(setIsZoomed)(false); + onZoomOutWorklet?.(); animationProgress.value = withSpring(0, exitConfig); scale.value = withSpring(MIN_IMAGE_SCALE, exitConfig); translateX.value = withSpring(0, exitConfig); @@ -289,10 +344,11 @@ export const ZoomableWrapper = ({ (Math.abs(event?.velocityY) ?? 0) - (Math.abs(event?.velocityX / 2) ?? 0) > THRESHOLD * targetScale && - fullSizeHeight * scale.value <= deviceHeight + fullSizeHeight * scale.value <= deviceHeightWithMaybeHiddenStatusBar ) { isZoomedValue.value = false; runOnJS(setIsZoomed)(false); + onZoomOutWorklet?.(); scale.value = withSpring(MIN_IMAGE_SCALE, exitConfig); animationProgress.value = withSpring(0, exitConfig); translateX.value = withSpring(0, exitConfig); @@ -305,18 +361,26 @@ export const ZoomableWrapper = ({ isZoomedValue.value && targetScale > breakingScaleX ) { - const projectedYCoordinate = targetTranslateY + event.velocityY / 8; + const projectedYCoordinate = + targetTranslateY + + event.velocityY / + 8 / + (fullSizeHeight / (containerHeightValue.value ?? 1)); const edgeBounceConfig = { damping: 60, mass: 2, stiffness: 600, - velocity: event.velocityY, + velocity: + event.velocityY / + (fullSizeHeight / (containerHeightValue.value ?? 1)), }; const flingConfig = { damping: 120, mass: 2, stiffness: 600, - velocity: event.velocityY, + velocity: + event.velocityY / + (fullSizeHeight / (containerHeightValue.value ?? 1)), }; if (projectedYCoordinate > maxDisplacementY) { translateY.value = withSpring(maxDisplacementY, edgeBounceConfig); @@ -332,18 +396,26 @@ export const ZoomableWrapper = ({ isZoomedValue.value && targetScale > breakingScaleX ) { - const projectedXCoordinate = targetTranslateX + event.velocityX / 8; + const projectedXCoordinate = + targetTranslateX + + event.velocityX / + 8 / + (fullSizeHeight / (containerHeightValue.value ?? 1)); const edgeBounceConfig = { damping: 60, mass: 2, stiffness: 600, - velocity: event.velocityX, + velocity: + event.velocityX / + (fullSizeHeight / (containerHeightValue.value ?? 1)), }; const flingConfig = { damping: 120, mass: 2, stiffness: 600, - velocity: event.velocityX, + velocity: + event.velocityX / + (fullSizeHeight / (containerHeightValue.value ?? 1)), }; if (projectedXCoordinate > maxDisplacementX) { translateX.value = withSpring(maxDisplacementX, edgeBounceConfig); @@ -364,7 +436,9 @@ export const ZoomableWrapper = ({ ) { scale.value = ctx.startScale - - ((ctx.startY + Math.abs(event.translationY)) / deviceHeight / 2) * + ((ctx.startY + Math.abs(event.translationY)) / + deviceHeightWithMaybeHiddenStatusBar / + 2) * ctx.startScale; } if (event.numberOfPointers === 2) { @@ -377,7 +451,8 @@ export const ZoomableWrapper = ({ // lock y translation on horizontal swipe if ( ctx.startScale <= MIN_IMAGE_SCALE || - ctx.startScale * fullSizeHeight >= deviceHeight || + ctx.startScale * fullSizeHeight >= + deviceHeightWithMaybeHiddenStatusBar || ctx.numberOfPointers === 2 || event.numberOfPointers === 2 || !(Math.abs(ctx.startVelocityX) / Math.abs(ctx.startVelocityY) > 1) @@ -460,18 +535,22 @@ export const ZoomableWrapper = ({ if (!isZoomedValue.value) { isZoomedValue.value = true; runOnJS(setIsZoomed)(true); + onZoomInWorklet?.(); animationProgress.value = withSpring(1, enterConfig); } else if ( scale.value === MIN_IMAGE_SCALE && ((event.absoluteY > 0 && - event.absoluteY < (deviceHeight - fullSizeHeight) / 2) || - (event.absoluteY <= deviceHeight && + event.absoluteY < + (deviceHeightWithMaybeHiddenStatusBar - fullSizeHeight) / 2) || + (event.absoluteY <= deviceHeightWithMaybeHiddenStatusBar && event.absoluteY > - deviceHeight - (deviceHeight - fullSizeHeight) / 2)) + deviceHeightWithMaybeHiddenStatusBar - + (deviceHeightWithMaybeHiddenStatusBar - fullSizeHeight) / 2)) ) { // dismiss if tap was outside image bounds isZoomedValue.value = false; runOnJS(setIsZoomed)(false); + onZoomOutWorklet?.(); animationProgress.value = withSpring(0, exitConfig); } }, @@ -487,22 +566,27 @@ export const ZoomableWrapper = ({ } else { // zoom to tapped coordinates and prevent detachment from screen edges const centerX = deviceWidth / 2; - const centerY = deviceHeight / 2; + const centerY = deviceHeightWithMaybeHiddenStatusBar / 2; const scaleTo = Math.min( - Math.max(deviceHeight / fullSizeHeight, 2.5), + Math.max( + deviceHeightWithMaybeHiddenStatusBar / fullSizeHeight, + 2.5 + ), MAX_IMAGE_SCALE ); const zoomToX = ((centerX - event.absoluteX) * scaleTo) / zooming; const zoomToY = ((centerY - event.absoluteY) * scaleTo) / zooming; const breakingScaleX = deviceWidth / fullSizeWidth; - const breakingScaleY = deviceHeight / fullSizeHeight; + const breakingScaleY = + deviceHeightWithMaybeHiddenStatusBar / fullSizeHeight; const maxDisplacementX = (deviceWidth * (Math.max(1, scaleTo / breakingScaleX) - 1)) / 2 / zooming; const maxDisplacementY = - (deviceHeight * (Math.max(1, scaleTo / breakingScaleY) - 1)) / + (deviceHeightWithMaybeHiddenStatusBar * + (Math.max(1, scaleTo / breakingScaleY) - 1)) / 2 / zooming; @@ -558,14 +642,13 @@ export const ZoomableWrapper = ({ return ( {}} scaleTo={1} - style={{ alignItems: 'center', zIndex: 10 }} + style={{ alignItems: 'center', zIndex: 1 }} > ({ - align: 'center', - color: colors.whiteLabel, - letterSpacing: 'zero', - size: 24, - weight: 'semibold', -}))``; - -const RegisterEnsFab = ({ disabled, ...props }) => { - const { navigate } = useNavigation(); - const { colors } = useTheme(); - - const handlePress = useCallback(() => { - navigate(Routes.REGISTER_ENS_NAVIGATOR); - }, [navigate]); - - return ( - - ENS - - ); -}; - -export default magicMemo(RegisterEnsFab, ['disabled', 'isReadOnlyWallet']); diff --git a/src/components/fab/index.js b/src/components/fab/index.js index 340f8a99d65..46573d81c2e 100644 --- a/src/components/fab/index.js +++ b/src/components/fab/index.js @@ -5,6 +5,5 @@ export { default as FloatingActionButton, FloatingActionButtonSize, } from './FloatingActionButton'; -export { default as SearchFab } from './SearchFab'; export { default as SendFab } from './SendFab'; -export { default as RegisterEnsFab } from './RegisterEnsFab'; +export { default as SearchFab } from './SearchFab'; diff --git a/src/components/fields/AddressField.js b/src/components/fields/AddressField.js index 7fb3bdaeee2..24eb96784f0 100644 --- a/src/components/fields/AddressField.js +++ b/src/components/fields/AddressField.js @@ -48,13 +48,13 @@ const formatValue = value => : value; const AddressField = ( - { address, autoFocus, name, onChange, onFocus, testID, ...props }, + { address, autoFocus, editable, name, onChange, onFocus, testID, ...props }, ref ) => { const { isTinyPhone } = useDimensions(); const { colors } = useTheme(); const { clipboard, setClipboard } = useClipboard(); - const [inputValue, setInputValue] = useState(''); + const [inputValue, setInputValue] = useState(address || ''); const [isValid, setIsValid] = useState(false); const expandAbbreviatedClipboard = useCallback(() => { @@ -78,11 +78,11 @@ const AddressField = ( ); useEffect(() => { - if (address !== inputValue || name !== inputValue) { - setInputValue(name || address); + if (name !== inputValue || name !== address) { + setInputValue(name); validateAddress(address); } - }, [address, inputValue, name, validateAddress]); + }, [address, editable, inputValue, name, validateAddress]); return ( @@ -90,6 +90,7 @@ const AddressField = ( {...props} autoFocus={autoFocus} color={isValid ? colors.appleBlue : colors.dark} + editable={editable} onBlur={expandAbbreviatedClipboard} onChange={handleChange} onChangeText={setInputValue} diff --git a/src/components/header/ProfileHeaderButton.js b/src/components/header/ProfileHeaderButton.js index 992773c98cf..4559c2cc277 100644 --- a/src/components/header/ProfileHeaderButton.js +++ b/src/components/header/ProfileHeaderButton.js @@ -32,7 +32,7 @@ export default function ProfileHeaderButton() { > {accountImage ? ( - + ) : ( { + return ( + + + + + ); +}; + +export default BTCIcon; diff --git a/src/components/icons/svg/DOGEIcon.js b/src/components/icons/svg/DOGEIcon.js new file mode 100644 index 00000000000..c78e39d515d --- /dev/null +++ b/src/components/icons/svg/DOGEIcon.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { Path } from 'react-native-svg'; +import Svg from '../Svg'; + +const DOGEIcon = ({ colors = undefined, color = colors.black, ...props }) => { + return ( + + + + ); +}; + +export default DOGEIcon; diff --git a/src/components/icons/svg/DiscordIcon.js b/src/components/icons/svg/DiscordIcon.js new file mode 100644 index 00000000000..67eaa28ec1f --- /dev/null +++ b/src/components/icons/svg/DiscordIcon.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { Path } from 'react-native-svg'; +import Svg from '../Svg'; + +const DiscordIcon = ({ + colors = undefined, + color = colors.black, + ...props +}) => ( + + + +); + +export default DiscordIcon; diff --git a/src/components/icons/svg/ENSIcon.js b/src/components/icons/svg/ENSIcon.js new file mode 100644 index 00000000000..694c0b27624 --- /dev/null +++ b/src/components/icons/svg/ENSIcon.js @@ -0,0 +1,80 @@ +import * as React from 'react'; +import Svg, { Defs, G, LinearGradient, Path, Stop } from 'react-native-svg'; + +const ENSIcon = props => ( + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default ENSIcon; diff --git a/src/components/icons/svg/GitHubIcon.js b/src/components/icons/svg/GitHubIcon.js new file mode 100644 index 00000000000..f13b280263e --- /dev/null +++ b/src/components/icons/svg/GitHubIcon.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { Path } from 'react-native-svg'; +import Svg from '../Svg'; + +const GitHubIcon = ({ colors = undefined, color = colors.black, ...props }) => { + return ( + + + + ); +}; + +export default GitHubIcon; diff --git a/src/components/icons/svg/InstagramIcon.js b/src/components/icons/svg/InstagramIcon.js new file mode 100644 index 00000000000..515382b8b35 --- /dev/null +++ b/src/components/icons/svg/InstagramIcon.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { Path } from 'react-native-svg'; +import Svg from '../Svg'; + +const InstagramIcon = ({ + colors = undefined, + color = colors.black, + ...props +}) => { + return ( + + + + ); +}; + +export default InstagramIcon; diff --git a/src/components/icons/svg/LTCIcon.js b/src/components/icons/svg/LTCIcon.js new file mode 100644 index 00000000000..86ef14ad79a --- /dev/null +++ b/src/components/icons/svg/LTCIcon.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { Path } from 'react-native-svg'; +import Svg from '../Svg'; + +const LTCIcon = ({ colors = undefined, color = colors.black, ...props }) => { + return ( + + + + ); +}; + +export default LTCIcon; diff --git a/src/components/icons/svg/LargeCheckmarkIcon.tsx b/src/components/icons/svg/LargeCheckmarkIcon.tsx new file mode 100644 index 00000000000..afff786f3c9 --- /dev/null +++ b/src/components/icons/svg/LargeCheckmarkIcon.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { Path, Svg } from 'react-native-svg'; + +export const LargeCheckmarkIcon = () => ( + + + +); diff --git a/src/components/icons/svg/SnapchatIcon.js b/src/components/icons/svg/SnapchatIcon.js new file mode 100644 index 00000000000..d9a8674f058 --- /dev/null +++ b/src/components/icons/svg/SnapchatIcon.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { Path } from 'react-native-svg'; +import Svg from '../Svg'; + +const SnapchatIcon = ({ + colors = undefined, + color = colors.black, + ...props +}) => { + return ( + + + + ); +}; + +export default SnapchatIcon; diff --git a/src/components/icons/svg/ThreeDotsIcon.js b/src/components/icons/svg/ThreeDotsIcon.js index d742462cd50..83489f9850b 100644 --- a/src/components/icons/svg/ThreeDotsIcon.js +++ b/src/components/icons/svg/ThreeDotsIcon.js @@ -1,33 +1,57 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { Circle, G } from 'react-native-svg'; +import { Circle, G, Path } from 'react-native-svg'; import Svg from '../Svg'; -const ThreeDotsIcon = ({ color, colors, tightDots, circle, ...props }) => ( - - - + smallDots ? ( + + - - - - - -); + + + + ) : ( + + + + + + + + + ); ThreeDotsIcon.propTypes = { color: PropTypes.string, + smallDots: PropTypes.bool, tightDots: PropTypes.bool, }; diff --git a/src/components/icons/svg/TwitterIcon.js b/src/components/icons/svg/TwitterIcon.js index 66e1c9b51af..0aef4811379 100644 --- a/src/components/icons/svg/TwitterIcon.js +++ b/src/components/icons/svg/TwitterIcon.js @@ -2,7 +2,11 @@ import React from 'react'; import { Path } from 'react-native-svg'; import Svg from '../Svg'; -const TwitterIcon = ({ colors, color = colors.black, ...props }) => { +const TwitterIcon = ({ + colors = undefined, + color = colors.black, + ...props +}) => { return ( ({ + default: [], + key: 'imagePreviewOverlay.ids', +}); + +const aspectRatioAtom = atomFamily({ + default: null, + key: 'imagePreviewOverlay.aspectRatio', +}); +const backgroundMaskAtom = atomFamily({ + default: null, + key: 'imagePreviewOverlay.backgroundMaskAtom', +}); +const borderRadiusAtom = atomFamily({ + default: 16, + key: 'imagePreviewOverlay.borderRadius', +}); +const disableAnimationsAtom = atomFamily({ + default: false, + key: 'imagePreviewOverlay.disableAnimations', +}); +const disableEnteringWithPinchAtom = atomFamily({ + default: false, + key: 'imagePreviewOverlay.disableEnteringWithPinch', +}); +const hasShadowAtom = atomFamily({ + default: false, + key: 'imagePreviewOverlay.hasShadow', +}); +const heightAtom = atomFamily({ + default: 0, + key: 'imagePreviewOverlay.height', +}); +const hideStatusBarAtom = atomFamily({ + default: true, + key: 'imagePreviewOverlay.hideStatusBar', +}); +const hostComponentAtom = atomFamily({ + default: , + key: 'imagePreviewOverlay.hostComponent', +}); +const imageUrlAtom = atomFamily({ + default: '', + key: 'imagePreviewOverlay.imageUrl', +}); +const widthAtom = atomFamily({ + default: 0, + key: 'imagePreviewOverlay.width', +}); +const xOffsetAtom = atomFamily({ + default: -1, + key: 'imagePreviewOverlay.xOffset', +}); +const yOffsetAtom = atomFamily({ + default: 0, + key: 'imagePreviewOverlay.yOffset', +}); + +const ImageOverlayConfigContext = createContext<{ + enableZoom: boolean; + useBackgroundOverlay?: boolean; +}>({ + enableZoom: false, +}); + +const enterConfig = { + damping: 40, + mass: 1.5, + stiffness: 600, +}; +const exitConfig = { + damping: 68, + mass: 2, + stiffness: 800, +}; + +type ImagePreviewOverlayProps = { + backgroundOverlay?: React.ReactElement; + children: React.ReactNode; + enableZoom?: boolean; + opacity?: SharedValue; + useBackgroundOverlay?: boolean; + yPosition?: SharedValue; +}; + +export default function ImagePreviewOverlay({ + backgroundOverlay, + children, + enableZoom = true, + opacity, + useBackgroundOverlay = true, + yPosition: givenYPosition, +}: ImagePreviewOverlayProps) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const yPosition = givenYPosition || useSharedValue(0); + + return ( + + + {children} + {enableZoom && ( + + )} + + + ); +} + +type ImagePreviewsProps = { + backgroundOverlay?: React.ReactElement; + opacity?: SharedValue; + yPosition: SharedValue; +}; + +function ImagePreviews({ + backgroundOverlay, + opacity, + yPosition, +}: ImagePreviewsProps) { + const ids = useRecoilValue(idsAtom); + return ( + <> + {ids.map((id, index) => ( + + ))} + + ); +} + +type ImagePreviewProps = { + backgroundOverlay?: React.ReactElement; + index: number; + id: string; + opacity?: SharedValue; + yPosition: SharedValue; +}; + +function ImagePreview({ + backgroundOverlay, + index, + id, + opacity: givenOpacity, + yPosition, +}: ImagePreviewProps) { + const { useBackgroundOverlay } = useContext(ImageOverlayConfigContext); + const { height: deviceHeight, width: deviceWidth } = useDimensions(); + + const aspectRatio = useRecoilValue(aspectRatioAtom(id)); + const backgroundMask = useRecoilValue(backgroundMaskAtom(id)); + const borderRadius = useRecoilValue(borderRadiusAtom(id)); + const disableAnimations = useRecoilValue(disableAnimationsAtom(id)); + const disableEnteringWithPinch = useRecoilValue( + disableEnteringWithPinchAtom(id) + ); + const hasShadow = useRecoilValue(hasShadowAtom(id)); + const height = useRecoilValue(heightAtom(id)); + const hideStatusBar = useRecoilValue(hideStatusBarAtom(id)); + const hostComponent = useRecoilValue(hostComponentAtom(id)); + const imageUrl = useRecoilValue(imageUrlAtom(id)); + const width = useRecoilValue(widthAtom(id)); + const xOffset = useRecoilValue(xOffsetAtom(id)); + const yOffset = useRecoilValue(yOffsetAtom(id)); + // eslint-disable-next-line react-hooks/rules-of-hooks + const opacity = givenOpacity || useSharedValue(1); + + const { colorMode } = useColorMode(); + + const progress = useSharedValue(0); + + const yDisplacement = useDerivedValue(() => { + return yPosition.value - yOffset; + }); + + const handleZoomOut = useCallback(() => { + 'worklet'; + progress.value = withSpring(0, exitConfig); + }, [progress]); + + const handleZoomIn = useCallback(() => { + 'worklet'; + progress.value = withSpring(1, enterConfig); + }, [progress]); + + const backgroundMaskStyle = useAnimatedStyle(() => ({ + zIndex: progress.value > 0 ? index + 1 : index, + })); + const overlayStyle = useAnimatedStyle(() => ({ + opacity: 1 * progress.value, + transform: [ + { + translateY: + yPosition.value - (hideStatusBar ? SheetHandleFixedToTopHeight : 0), + }, + ], + zIndex: progress.value > 0 ? index + 2 : -2, + })); + const containerStyle = useAnimatedStyle(() => ({ + zIndex: progress.value > 0 ? index + 10 : index, + })); + + const ready = + id && imageUrl && height > 0 && width > 0 && xOffset >= 0 && aspectRatio; + + if (!ready) return null; + return ( + <> + {backgroundMask === 'avatar' && ( + + + + {({ backgroundColor }) => ( + + )} + + + + )} + {useBackgroundOverlay && ( + <> + {backgroundOverlay ? ( + + {backgroundOverlay} + + ) : ( + + {ios && ( + + + + + + + )} + + + )} + + )} + + + + {hostComponent} + + + + + ); +} + +const ASPECT_RATIOS = { + avatar: 1, + cover: 3, +}; + +export function ImagePreviewOverlayTarget({ + aspectRatioType, + backgroundMask, + borderRadius = 16, + children: children_, + deferOverlayTimeout = 0, + disableEnteringWithPinch = false, + enableZoomOnPress = true, + hasShadow = false, + height: givenHeight, + hideStatusBar = true, + imageUrl = '', + onPress, + topOffset = 85, + uri, +}: { + backgroundMask?: 'avatar'; + borderRadius?: number; + children: React.ReactElement; + enableZoomOnPress?: boolean; + deferOverlayTimeout?: number; + disableEnteringWithPinch?: boolean; + hasShadow?: boolean; + onPress?: PressableProps['onPress']; + height?: BoxProps['height']; + hideStatusBar?: boolean; + imageUrl?: string; + topOffset?: number; +} & ( + | { + aspectRatioType?: never; + uri: string; + } + | { + aspectRatioType: 'avatar' | 'cover'; + uri?: never; + } +)) { + const { enableZoom: enableZoom_ } = useContext(ImageOverlayConfigContext); + const enableZoom = enableZoom_ && imageUrl; + + const id = useMemo(() => uniqueId(), []); + + const [height, setHeight] = useRecoilState(heightAtom(id)); + const [width, setWidth] = useRecoilState(widthAtom(id)); + + const setIds = useSetRecoilState(idsAtom); + const setHostComponent = useSetRecoilState(hostComponentAtom(id)); + + const setAspectRatio = useSetRecoilState(aspectRatioAtom(id)); + const setBackgroundMask = useSetRecoilState(backgroundMaskAtom(id)); + const setBorderRadius = useSetRecoilState(borderRadiusAtom(id)); + const setDisableAnimations = useSetRecoilState(disableAnimationsAtom(id)); + const setDisableEnteringWithPinch = useSetRecoilState( + disableEnteringWithPinchAtom(id) + ); + const setHasShadow = useSetRecoilState(hasShadowAtom(id)); + const setHideStatusBar = useSetRecoilState(hideStatusBarAtom(id)); + const setImageUrl = useSetRecoilState(imageUrlAtom(id)); + const setXOffset = useSetRecoilState(xOffsetAtom(id)); + const setYOffset = useSetRecoilState(yOffsetAtom(id)); + + useEffect(() => { + if (backgroundMask) { + setBackgroundMask(backgroundMask); + } + setBorderRadius(borderRadius); + setDisableAnimations(!enableZoomOnPress); + setDisableEnteringWithPinch(disableEnteringWithPinch); + setHasShadow(hasShadow); + setHideStatusBar(hideStatusBar); + setImageUrl(imageUrl); + setIds(ids => [...ids, id]); + }, [ + backgroundMask, + borderRadius, + enableZoomOnPress, + disableEnteringWithPinch, + hasShadow, + hideStatusBar, + id, + imageUrl, + setBackgroundMask, + setBorderRadius, + setDisableAnimations, + setDisableEnteringWithPinch, + setHasShadow, + setHideStatusBar, + setIds, + setImageUrl, + ]); + + // If we are not given an `aspectRatioType`, then we will need to + // calculate it from the uri. + const calculatedAspectRatio = usePersistentAspectRatio(uri || ''); + + const aspectRatio = useMemo( + () => + aspectRatioType + ? ASPECT_RATIOS[aspectRatioType] + : calculatedAspectRatio.result, + [aspectRatioType, calculatedAspectRatio] + ); + useEffect(() => { + if (aspectRatio) { + setAspectRatio(aspectRatio); + if (width) { + setHeight(width / aspectRatio); + } + } + }, [aspectRatio, width, setAspectRatio, setHeight]); + + const zoomableWrapperRef = useRef(); + const hasMounted = useRef(false); + + const handleLayout = useCallback( + ({ nativeEvent }) => { + const { + layout: { width }, + } = nativeEvent; + if (width && aspectRatio) { + setWidth(width); + } + setTimeout( + () => { + if (zoomableWrapperRef.current && !hasMounted.current) { + zoomableWrapperRef.current?.measure((...args: any) => { + const xOffset = args[4]; + const yOffset = args[5]; + typeof xOffset === 'number' && setXOffset(xOffset); + typeof yOffset === 'number' && setYOffset(yOffset - topOffset); + hasMounted.current = true; + }); + } + }, + android ? 500 : 0 + ); + }, + [aspectRatio, setWidth, setXOffset, setYOffset, topOffset] + ); + + const children = useMemo(() => { + if (!onPress) return children_; + return {children_}; + }, [children_, onPress]); + + useEffect(() => { + if (!enableZoom) return; + + if (deferOverlayTimeout) { + setTimeout(() => setHostComponent(children), deferOverlayTimeout); + } else { + setHostComponent(children); + } + }, [children, enableZoom, setHostComponent, deferOverlayTimeout, uri]); + + const [renderPlaceholder, setRenderPlaceholder] = useState(true); + useEffect(() => { + if (!enableZoom) return; + if (width) { + InteractionManager.runAfterInteractions(() => { + setTimeout( + () => setRenderPlaceholder(false), + 500 + deferOverlayTimeout + ); + }); + } + }, [enableZoom, deferOverlayTimeout, width]); + + return ( + + + {children} + + + ); +} diff --git a/src/components/images/ImgixImage.tsx b/src/components/images/ImgixImage.tsx index 0476f418d4f..bb6f7fe6623 100644 --- a/src/components/images/ImgixImage.tsx +++ b/src/components/images/ImgixImage.tsx @@ -66,17 +66,30 @@ const preload = (sources: Source[], size?: Number, fm?: String): void => { return; }; +const getCachePath = (source: Source) => + FastImage.getCachePath(maybeSignSource(source)); + const ImgixImageWithForwardRef = React.forwardRef( (props: ImgixImageProps, ref: React.Ref) => ( ) ); -const { cacheControl, contextTypes, priority, resizeMode } = FastImage; +const { + cacheControl, + clearDiskCache, + clearMemoryCache, + contextTypes, + priority, + resizeMode, +} = FastImage; export default Object.assign(ImgixImageWithForwardRef, { cacheControl, + clearDiskCache, + clearMemoryCache, contextTypes, + getCachePath, preload, priority, resizeMode, diff --git a/src/components/inputs/InlineField.tsx b/src/components/inputs/InlineField.tsx index afd595508b7..bd34b7f53a2 100644 --- a/src/components/inputs/InlineField.tsx +++ b/src/components/inputs/InlineField.tsx @@ -1,62 +1,80 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { TextInputProps } from 'react-native'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { Alert, TextInputProps } from 'react-native'; +import { useTheme } from '../../context/ThemeContext'; +import ButtonPressAnimation from '../animations/ButtonPressAnimation'; import Input from './Input'; import { + Bleed, Column, Columns, + Inline, Inset, Text, useTextStyle, } from '@rainbow-me/design-system'; +import { useDimensions } from '@rainbow-me/hooks'; const textSize = 16; export type InlineFieldProps = { + autoFocus?: TextInputProps['autoFocus']; defaultValue?: string; + errorMessage?: string; label: string; placeholder?: string; inputProps?: Partial; onChangeText: (text: string) => void; + onFocus?: TextInputProps['onFocus']; onEndEditing?: TextInputProps['onEndEditing']; + selectionColor?: string; + startsWith?: string; validations?: { - allowCharacterRegex?: { match: RegExp }; - maxLength?: { value: number }; + onChange?: { + match?: RegExp; + }; }; value?: string; + testID?: string; + key?: string; }; export default function InlineField({ + autoFocus, defaultValue, + errorMessage, label, onChangeText, + onFocus, placeholder, inputProps, validations, onEndEditing, + selectionColor, + startsWith, value, + testID, }: InlineFieldProps) { + const { colors } = useTheme(); + const { width } = useDimensions(); + const paddingVertical = 17; const textStyle = useTextStyle({ size: `${textSize}px`, weight: 'bold' }); const [inputHeight, setInputHeight] = useState(textSize); - const handleContentSizeChange = useCallback( - ({ nativeEvent }) => { - if (inputProps?.multiline) { - const contentHeight = nativeEvent.contentSize.height; - if (contentHeight > 30) { - setInputHeight(nativeEvent.contentSize.height); - } else { - setInputHeight(textSize); - } - } - }, - [inputProps?.multiline] - ); + const handleContentSizeChange = useCallback(({ nativeEvent }) => { + const contentHeight = + nativeEvent.contentSize.height - textSize - paddingVertical; + if (contentHeight > 30) { + setInputHeight(contentHeight); + } else { + setInputHeight(textSize); + } + }, []); const handleChangeText = useCallback( text => { - const { allowCharacterRegex } = validations || {}; - if (!allowCharacterRegex) { + const { onChange: { match = null } = {} } = validations || {}; + if (!match) { onChangeText(text); return; } @@ -64,7 +82,7 @@ export default function InlineField({ onChangeText(text); return; } - if (allowCharacterRegex?.match.test(text)) { + if (match?.test(text)) { onChangeText(text); return; } @@ -72,46 +90,102 @@ export default function InlineField({ [onChangeText, validations] ); + const valueRef = useRef(value); const style = useMemo( () => ({ ...textStyle, - height: inputHeight + paddingVertical * 2 + (android ? 2 : 0), lineHeight: android ? textStyle.lineHeight : undefined, marginBottom: 0, marginTop: 0, + minHeight: inputHeight + paddingVertical * 2 + (android ? 2 : 0), + paddingBottom: inputProps?.multiline ? (ios ? 15 : 7) : 0, paddingTop: inputProps?.multiline ? android ? 11 : 15 : android - ? 15 + ? valueRef.current + ? 16 + : 11 : 0, textAlignVertical: 'top', + width: startsWith + ? ios + ? 0.55 * width + : 0.56 * width + : ios + ? 0.6 * width + : 0.61 * width, }), - [textStyle, inputHeight, inputProps?.multiline] + [textStyle, inputHeight, inputProps?.multiline, startsWith, width] ); return ( - - {label} - + + + {label} + + {errorMessage && ( + + Alert.alert(errorMessage)}> + + + 􀇿 + + + + + )} + - + + + {startsWith && ( + + + {startsWith} + + + )} + + + ); } diff --git a/src/components/large-countdown-clock/LargeCountdownClock.tsx b/src/components/large-countdown-clock/LargeCountdownClock.tsx new file mode 100644 index 00000000000..c16aeb9b485 --- /dev/null +++ b/src/components/large-countdown-clock/LargeCountdownClock.tsx @@ -0,0 +1,293 @@ +import useCountdown from '@bradgarropy/use-countdown'; +import React, { ReactNode, useState } from 'react'; +import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; +import Animated, { + Easing, + useAnimatedProps, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import { Circle, Defs, G, RadialGradient, Stop, Svg } from 'react-native-svg'; +import { CheckmarkAnimation } from '../animations/CheckmarkAnimation'; +import { SeparatorDots } from './SeparatorDots'; +import { useVariableFont } from './useVariableFont'; +import { + Box, + useForegroundColor, + useTextStyle, +} from '@rainbow-me/design-system'; + +type LargeCountdownClockProps = { + initialSeconds?: number; + initialMinutes?: number; + minutes?: number; + seconds: number; + onFinished: () => void; +}; + +const PROGRESS_RADIUS = 60; +const PROGRESS_STROKE_WIDTH = 8; +const PROGRESS_CENTER_COORDINATE = PROGRESS_RADIUS + PROGRESS_STROKE_WIDTH / 2; +const PROGRESS_STROKE_FULL_LENGTH = Math.round(2 * Math.PI * PROGRESS_RADIUS); + +export default function LargeCountdownClock({ + minutes, + seconds, + initialSeconds, + initialMinutes, + onFinished, +}: LargeCountdownClockProps) { + const [completed, setCompleted] = useState(false); + const countdown = useCountdown({ + format: 'm:ss', + minutes, + onCompleted: () => { + setCompleted(true); + ReactNativeHapticFeedback.trigger('notificationSuccess'); + setTimeout(() => { + onFinished(); + }, 1500); + }, + seconds, + }); + const { + displayMinutes, + displaySeconds, + fontSize, + minuteEndsWithOne, + lineHeight, + separatorSize, + } = useVariableFont(countdown.minutes, countdown.seconds); + + const accentColor = useForegroundColor('accent'); + + // convert clock time to seconds + const mins = initialMinutes ?? minutes; + const secs = initialSeconds ?? seconds; + const totalSeconds = mins ? mins * 60 + secs : secs; + // convert remaining clock time to seconds + const timeRemaining = countdown.minutes * 60 + countdown.seconds; + // save full stroke value to use in animation + const offset = useSharedValue(PROGRESS_STROKE_FULL_LENGTH); + // calculate stroke value based on remaining time + offset.value = + PROGRESS_STROKE_FULL_LENGTH - + (timeRemaining / totalSeconds) * PROGRESS_STROKE_FULL_LENGTH; + + const animatedStroke = useAnimatedProps(() => ({ + strokeDashoffset: withTiming(offset.value, { + duration: 1000, + easing: Easing.linear, + }), + })); + + const entering = () => { + 'worklet'; + const animations = { + opacity: withTiming(1, { duration: 350 }), + transform: [ + { + scale: withSpring(1, { + damping: 12, + restDisplacementThreshold: 0.001, + restSpeedThreshold: 0.001, + stiffness: 260, + }), + }, + ], + }; + const initialValues = { + opacity: 0, + transform: [{ scale: 0.5 }], + }; + return { + animations, + initialValues, + }; + }; + + const clockExiting = () => { + 'worklet'; + const animations = { + opacity: withTiming(0, { duration: 150 }), + transform: [ + { + scale: withTiming(2, { duration: 200 }), + }, + ], + }; + const initialValues = { + opacity: 1, + transform: [{ scale: 1 }], + }; + return { + animations, + initialValues, + }; + }; + + const minutesExiting = () => { + 'worklet'; + const animations = { + opacity: withTiming(0, { duration: 150 }), + transform: [ + { + scale: withTiming(0.5, { duration: 150 }), + }, + ], + }; + const initialValues = { + opacity: 1, + transform: [{ scale: 1 }], + }; + return { + animations, + initialValues, + }; + }; + + return ( + + {completed ? ( + + ) : ( + + {displayMinutes ? ( + + + {displayMinutes} + + + + {displaySeconds} + + + ) : ( + = 10 && displaySeconds < 20 ? 3 : 0, + position: 'absolute', + }} + > + + {displaySeconds} + + + )} + + + + + + + + + + + + + + )} + + ); +} + +const AnimatedCircle = Animated.createAnimatedComponent(Circle); + +const ClockText = ({ + children, + fontSize, + lineHeight, +}: { + children: ReactNode; + fontSize: number; + lineHeight: number; +}) => { + const textStyles = useTextStyle({ + align: 'center', + color: 'accent', + tabularNumbers: true, + weight: 'heavy', + }); + return ( + + {children} + + ); +}; diff --git a/src/components/large-countdown-clock/SeparatorDots.tsx b/src/components/large-countdown-clock/SeparatorDots.tsx new file mode 100644 index 00000000000..76824d6ed58 --- /dev/null +++ b/src/components/large-countdown-clock/SeparatorDots.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Box, useForegroundColor } from '@rainbow-me/design-system'; + +type SeparatorDotsProps = { + minuteEndsWithOne: boolean; + size: number; +}; + +export function SeparatorDots({ size, minuteEndsWithOne }: SeparatorDotsProps) { + const accentColor = useForegroundColor('accent'); + return ( + + + + + ); +} diff --git a/src/components/large-countdown-clock/useVariableFont.ts b/src/components/large-countdown-clock/useVariableFont.ts new file mode 100644 index 00000000000..1ecaf23f9cc --- /dev/null +++ b/src/components/large-countdown-clock/useVariableFont.ts @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; + +type VariableFontReturnOptions = { + displayMinutes: number; + displaySeconds: number | string; + lineHeight: number; + minuteEndsWithOne: boolean; + separatorSize: number; + fontSize: number; +}; + +export function useVariableFont( + min: number, + sec: number +): VariableFontReturnOptions { + const [fontSize, setFontSize] = useState(50); + const [lineHeight, setLineHeight] = useState(108); + const [separatorSize, setSeparatorSize] = useState(7); + const [minuteEndsWithOne, setMinuteEndsWithOne] = useState(false); + const [displayMinutes, setDisplayMinutes] = useState(min); + const [displaySeconds, setDisplaySeconds] = useState(sec); + + useEffect(() => { + setDisplayMinutes(min); + setDisplaySeconds(sec); + setMinuteEndsWithOne(false); + + if (min > 0 && sec < 10) { + // add leading zero in front of single seconds when there are minutes + setDisplaySeconds(`0${sec}`); + } + + if (min > 0) { + setFontSize(36); + setLineHeight(41); + setSeparatorSize(7); + } + + if (min >= 10) { + setFontSize(26); + setLineHeight(28); + setSeparatorSize(5); + } + + if (min % 10 === 1) { + setMinuteEndsWithOne(true); + } + }, [displayMinutes, displaySeconds, min, sec]); + + return { + displayMinutes, + displaySeconds, + fontSize, + lineHeight, + minuteEndsWithOne, + separatorSize, + }; +} diff --git a/src/components/list/MarqueeList.js b/src/components/list/MarqueeList.js index f3ab88a0731..b1ee831b851 100644 --- a/src/components/list/MarqueeList.js +++ b/src/components/list/MarqueeList.js @@ -16,7 +16,6 @@ import Animated, { Value, withDecay, } from 'react-native-reanimated'; -import { TopMoverCoinRow } from '../coin-row'; import { withSpeed } from '@rainbow-me/utils'; const DECCELERATION = 0.998; @@ -240,33 +239,20 @@ const SwipeableList = ({ components, speed, testID }) => { ); }; -const MarqueeList = ({ items = [], speed, testID }) => { - const renderItemCallback = useCallback( - ({ item, index, onPressCancel, onPressStart, testID }) => ( - - ), - [] - ); - +const MarqueeList = ({ items = [], renderItem, speed, testID }) => { return ( <> ({ view: ios - ? ({ testID }) => renderItemCallback({ index, item, testID }) - : ({ onPressCancel, onPressStart, testID }) => - renderItemCallback({ + ? () => renderItem({ index, item, testID: item.testID }) + : ({ onPressCancel, onPressStart }) => + renderItem({ index, item, onPressCancel, onPressStart, - testID, + testID: item.testID, }), }))} speed={speed} diff --git a/src/components/send/SendContactList.js b/src/components/send/SendContactList.js index 36c9304d62b..40a705aaceb 100644 --- a/src/components/send/SendContactList.js +++ b/src/components/send/SendContactList.js @@ -5,7 +5,6 @@ import React, { useCallback, useMemo, useRef } from 'react'; import { SectionList } from 'react-native'; import * as DeviceInfo from 'react-native-device-info'; import LinearGradient from 'react-native-linear-gradient'; -import { useSafeArea } from 'react-native-safe-area-context'; import { FlyInAnimation } from '../animations'; import { ContactRow, SwipeableContactRow } from '../contacts'; import { SheetHandleFixedToTopHeight } from '../sheet'; @@ -19,8 +18,7 @@ import styled from '@rainbow-me/styled-components'; import { filterList } from '@rainbow-me/utils'; const KeyboardArea = styled.View({ - height: ({ insets, keyboardHeight }) => - DeviceInfo.hasNotch() ? keyboardHeight : keyboardHeight - insets.top, + height: ({ keyboardHeight }) => keyboardHeight, }); const rowHeight = 59; @@ -29,7 +27,7 @@ const getItemLayout = (data, index) => ({ length: rowHeight, offset: rowHeight * index, }); -const contentContainerStyle = { paddingBottom: 32, paddingTop: 7 }; +const contentContainerStyle = { paddingBottom: 17, paddingTop: 7 }; const keyExtractor = item => `SendContactList-${item.address}`; const SectionTitle = styled(Text).attrs({ @@ -76,7 +74,6 @@ export default function SendContactList({ }) { const { accountAddress } = useAccountSettings(); const { navigate } = useNavigation(); - const insets = useSafeArea(); const keyboardHeight = useKeyboardHeight(); const { isDarkMode } = useTheme(); @@ -96,12 +93,13 @@ export default function SendContactList({ }, []); const handleEditContact = useCallback( - ({ address, color, nickname }) => { + ({ address, color, ens, nickname }) => { navigate(Routes.MODAL_SCREEN, { additionalPadding: true, address, color, - contact: { address, color, nickname }, + ens, + nickname, type: 'contact_profile', }); }, @@ -112,6 +110,7 @@ export default function SendContactList({ ({ item, section }) => { const ComponentToReturn = section.id === 'contacts' ? SwipeableContactRow : ContactRow; + return ( = 3 && + filteredEnsSuggestions.length && tmp.push({ data: filteredEnsSuggestions, id: 'suggestions', @@ -239,7 +239,7 @@ export default function SendContactList({ > - {ios && } + {ios && } ); } diff --git a/src/components/send/SendHeader.js b/src/components/send/SendHeader.js index a781bb25232..4f1661fa32d 100644 --- a/src/components/send/SendHeader.js +++ b/src/components/send/SendHeader.js @@ -1,7 +1,7 @@ import { isHexString } from '@ethersproject/bytes'; import lang from 'i18n-js'; import { get, isEmpty, toLower } from 'lodash'; -import React, { Fragment, useCallback, useMemo } from 'react'; +import React, { Fragment, useCallback, useEffect, useMemo } from 'react'; import { ActivityIndicator, Keyboard } from 'react-native'; import { useTheme } from '../../context/ThemeContext'; import { useNavigation } from '../../navigation/Navigation'; @@ -9,12 +9,17 @@ import Divider from '../Divider'; import Spinner from '../Spinner'; import { ButtonPressAnimation } from '../animations'; import { PasteAddressButton } from '../buttons'; +import showDeleteContactActionSheet from '../contacts/showDeleteContactActionSheet'; import { AddressField } from '../fields'; import { Row } from '../layout'; import { SheetHandleFixedToTop, SheetTitle } from '../sheet'; import { Label, Text } from '../text'; +import useExperimentalFlag, { + PROFILES, +} from '@rainbow-me/config/experimentalHooks'; import { resolveNameOrAddress } from '@rainbow-me/handlers/web3'; import { removeFirstEmojiFromString } from '@rainbow-me/helpers/emojiHandler'; +import { isENSAddressFormat } from '@rainbow-me/helpers/validators'; import { useClipboard, useDimensions } from '@rainbow-me/hooks'; import Routes from '@rainbow-me/routes'; import styled from '@rainbow-me/styled-components'; @@ -70,6 +75,8 @@ export default function SendHeader({ contacts, hideDivider, isValidAddress, + fromProfile, + nickname, onChangeAddressInput, onFocus, onPressPaste, @@ -81,21 +88,19 @@ export default function SendHeader({ userAccounts, watchedAccounts, }) { + const profilesEnabled = useExperimentalFlag(PROFILES); const { setClipboard } = useClipboard(); const { isSmallPhone, isTinyPhone } = useDimensions(); const { navigate } = useNavigation(); const { colors } = useTheme(); - const contact = useMemo(() => { - return get(contacts, `${[toLower(recipient)]}`, defaultContactItem); - }, [contacts, recipient]); - const [hexAddress, setHexAddress] = useState(null); + const [hexAddress, setHexAddress] = useState(''); useEffect(() => { - if (isValidAddress && !contact.address) { + if (isValidAddress) { resolveAndStoreAddress(); } else { - setHexAddress(null); + setHexAddress(''); } async function resolveAndStoreAddress() { const hex = await resolveNameOrAddress(recipient); @@ -104,35 +109,63 @@ export default function SendHeader({ } setHexAddress(hex); } - }, [isValidAddress, recipient, setHexAddress, contact]); + }, [isValidAddress, recipient, setHexAddress]); + + const contact = useMemo(() => { + return get(contacts, `${[toLower(hexAddress)]}`, defaultContactItem); + }, [contacts, hexAddress]); const userWallet = useMemo(() => { return [...userAccounts, ...watchedAccounts].find( - account => toLower(account.address) === toLower(recipient) + account => toLower(account.address) === toLower(hexAddress || recipient) ); - }, [recipient, userAccounts, watchedAccounts]); + }, [recipient, userAccounts, watchedAccounts, hexAddress]); + + const isPreExistingContact = (contact?.nickname?.length || 0) > 0; + + const name = + removeFirstEmojiFromString( + userWallet?.label || contact?.nickname || nickname + ) || + userWallet?.ens || + contact?.ens || + recipient; const handleNavigateToContact = useCallback(() => { - let color = get(contact, 'color'); - let nickname = recipient; - if (color !== 0 && !color) { - const emoji = profileUtils.addressHashedEmoji(hexAddress); - color = profileUtils.addressHashedColorIndex(hexAddress) || 0; - nickname = isHexString(recipient) ? emoji : `${emoji} ${recipient}`; + let nickname = profilesEnabled + ? !isHexString(recipient) + ? recipient + : null + : recipient; + let color = ''; + if (!profilesEnabled) { + color = get(contact, 'color'); + if (color !== 0 && !color) { + const emoji = profileUtils.addressHashedEmoji(hexAddress); + color = profileUtils.addressHashedColorIndex(hexAddress) || 0; + nickname = isHexString(recipient) ? emoji : `${emoji} ${recipient}`; + } } android && Keyboard.dismiss(); navigate(Routes.MODAL_SCREEN, { additionalPadding: true, - address: isEmpty(contact.address) ? recipient : contact.address, + address: hexAddress, color, - contact: isEmpty(contact.address) - ? { color, nickname, temporary: true } - : contact, + contact, + ens: recipient, + nickname, onRefocusInput, type: 'contact_profile', }); - }, [contact, hexAddress, navigate, onRefocusInput, recipient]); + }, [ + contact, + hexAddress, + navigate, + onRefocusInput, + profilesEnabled, + recipient, + ]); const handleOpenContactActionSheet = useCallback(async () => { return showActionSheetWithOptions( @@ -141,57 +174,53 @@ export default function SendHeader({ destructiveButtonIndex: 0, options: [ lang.t('contacts.options.delete'), // <-- destructiveButtonIndex - lang.t('contacts.options.edit'), + profilesEnabled && isENSAddressFormat(recipient) + ? lang.t('contacts.options.view') + : lang.t('contacts.options.edit'), lang.t('wallet.settings.copy_address_capitalized'), lang.t('contacts.options.cancel'), // <-- cancelButtonIndex ], }, async buttonIndex => { if (buttonIndex === 0) { - showActionSheetWithOptions( - { - cancelButtonIndex: 1, - destructiveButtonIndex: 0, - options: [ - lang.t('contacts.options.delete'), - lang.t('contacts.options.cancel'), - ], + showDeleteContactActionSheet({ + address: hexAddress, + nickname: name, + onDelete: () => { + onChangeAddressInput(contact?.ens); }, - async buttonIndex => { - if (buttonIndex === 0) { - removeContact(recipient); - onRefocusInput(); - } else { - onRefocusInput(); - } - } - ); + removeContact: removeContact, + }); } else if (buttonIndex === 1) { - handleNavigateToContact(); - onRefocusInput(); + if (profilesEnabled && isENSAddressFormat(recipient)) { + navigate(Routes.PROFILE_SHEET, { + address: recipient, + fromRoute: 'SendHeader', + }); + } else { + handleNavigateToContact(); + onRefocusInput(); + } } else if (buttonIndex === 2) { - setClipboard(recipient); + setClipboard(hexAddress); onRefocusInput(); } } ); }, [ + contact?.ens, handleNavigateToContact, + hexAddress, + navigate, onRefocusInput, + profilesEnabled, recipient, removeContact, setClipboard, + name, + onChangeAddressInput, ]); - const isPreExistingContact = (contact?.nickname?.length || 0) > 0; - const name = useMemo( - () => - userWallet?.label - ? removeFirstEmojiFromString(userWallet.label) - : removeFirstEmojiFromString(contact.nickname), - [contact.nickname, userWallet?.label] - ); - return ( @@ -206,38 +235,40 @@ export default function SendHeader({ { + onChangeAddressInput(e); + setHexAddress(''); + }} onFocus={onFocus} ref={recipientFieldRef} testID="send-asset-form-field" /> - {isValidAddress && - !userWallet && - (hexAddress || !isEmpty(contact?.address)) && ( - + - - {isPreExistingContact ? '􀍡' : ` 􀉯 ${lang.t('button.save')}`} - - - )} + {isPreExistingContact ? '􀍡' : ` 􀉯 ${lang.t('button.save')}`} +
+
+ )} {isValidAddress && !hexAddress && isEmpty(contact?.address) && ( )} diff --git a/src/components/settings-menu/DevSection.js b/src/components/settings-menu/DevSection.js index d4dc763c594..144cb7f5be5 100644 --- a/src/components/settings-menu/DevSection.js +++ b/src/components/settings-menu/DevSection.js @@ -18,6 +18,7 @@ import { web3SetHttpProvider } from '@rainbow-me/handlers/web3'; import { RainbowContext } from '@rainbow-me/helpers/RainbowContext'; import networkTypes from '@rainbow-me/helpers/networkTypes'; import { useWallets } from '@rainbow-me/hooks'; +import { ImgixImage } from '@rainbow-me/images'; import { wipeKeychain } from '@rainbow-me/model/keychain'; import { clearAllStorages } from '@rainbow-me/model/mmkv'; import { Navigation } from '@rainbow-me/navigation'; @@ -123,6 +124,11 @@ const DevSection = () => { Restart(); }; + const clearImageCache = async () => { + ImgixImage.clearDiskCache(); + ImgixImage.clearImageCache(); + }; + const [errorObj, setErrorObj] = useState(null); const throwRenderError = () => { @@ -145,6 +151,10 @@ const DevSection = () => { label={`📷️ ${lang.t('developer_settings.clear_image_metadata_cache')}`} onPress={clearImageMetadataCache} /> + { /> {Object.keys(config) .sort() - .filter(key => defaultConfig[key].settings) + .filter(key => defaultConfig[key]?.settings) .map(key => ( (size === 'big' ? 56 : 46), - paddingBottom: ({ label }) => (label && containsEmoji(label) ? 4 : 2), + paddingBottom: ({ label }) => (label && containsEmoji(label) ? 2.5 : 1), paddingHorizontal: 19, zIndex: 1, }); @@ -57,11 +57,14 @@ const SheetActionButton = ({ isCharts = false, isTransparent = false, label = null, + lightShadows, + onPress, nftShadows, scaleTo = 0.9, size = null, testID = null, textColor: givenTextColor, + textSize, truncate = false, weight = 'semibold', ...props @@ -77,13 +80,13 @@ const SheetActionButton = ({ return [[0, 0, 0, colors.transparent, 0]]; } else return [ - [0, 10, 30, colors.shadow, isWhite ? 0.12 : 0.2], + [0, 10, 30, colors.shadow, isWhite ? 0.12 : lightShadows ? 0.15 : 0.2], [ 0, 5, 15, isDarkMode || isWhite ? colors.shadow : color, - isWhite ? 0.08 : 0.4, + isWhite ? 0.08 : lightShadows ? 0.3 : 0.4, ], ]; }, [ @@ -93,6 +96,7 @@ const SheetActionButton = ({ forceShadows, isTransparent, isDarkMode, + lightShadows, nftShadows, isWhite, ]); @@ -105,9 +109,10 @@ const SheetActionButton = ({ }} elevation={android ? elevation : null} isCharts={isCharts} + onPress={disabled ? () => undefined : onPress} overflowMargin={30} radiusAndroid={borderRadius} - scaleTo={scaleTo} + scaleTo={disabled ? 1 : scaleTo} size={size} testID={`${testID}-action-button`} {...props} @@ -137,7 +142,7 @@ const SheetActionButton = ({ align="center" color={textColor} numberOfLines={truncate ? 1 : undefined} - size={size === 'big' ? 'larger' : 'large'} + size={textSize ?? (size === 'big' ? 'larger' : 'large')} weight={weight} > {label} diff --git a/src/components/sheet/sheet-action-buttons/SheetActionButtonRow.js b/src/components/sheet/sheet-action-buttons/SheetActionButtonRow.js index 74b41265001..567f40f9481 100644 --- a/src/components/sheet/sheet-action-buttons/SheetActionButtonRow.js +++ b/src/components/sheet/sheet-action-buttons/SheetActionButtonRow.js @@ -32,8 +32,8 @@ function renderButton(child) { export default function SheetActionButtonRow({ children, - ignorePaddingBottom, - ignorePaddingTop, + ignorePaddingBottom = false, + ignorePaddingTop = false, paddingBottom = null, paddingHorizontal = null, }) { diff --git a/src/components/step-indicator/StepIndicator.tsx b/src/components/step-indicator/StepIndicator.tsx new file mode 100644 index 00000000000..f73d03f89ad --- /dev/null +++ b/src/components/step-indicator/StepIndicator.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import Animated, { + Easing, + useAnimatedStyle, + useDerivedValue, + withRepeat, + withSequence, + withTiming, +} from 'react-native-reanimated'; +import { Box, Columns, useForegroundColor } from '@rainbow-me/design-system'; +import { magicMemo } from '@rainbow-me/utils'; + +const PULSE_STEP_DURATION = 1000; + +type StepIndicatorProps = { + steps: number; // 1 indexed + currentStep: number; // set higher than `steps` to complete the animation +}; + +const StepIndicator = ({ steps, currentStep }: StepIndicatorProps) => { + const accentColor = useForegroundColor('accent'); + const accentColorTint = accentColor + '25'; + + const pulseStepOpacity = useDerivedValue(() => + withRepeat( + withSequence( + withTiming(0, { duration: PULSE_STEP_DURATION }), + withTiming(1, { duration: PULSE_STEP_DURATION }), + withTiming(0, { duration: PULSE_STEP_DURATION }) + ), + -1 + ) + ); + + const pulseStepTranslate = useDerivedValue(() => + withRepeat( + withSequence( + withTiming(-100, { duration: PULSE_STEP_DURATION }), + withTiming(-100, { duration: PULSE_STEP_DURATION }), + withTiming(0, { duration: PULSE_STEP_DURATION }) + ), + -1 + ) + ); + + const finishedStepFill = useDerivedValue(() => + withSequence( + withTiming(0, { duration: 300, easing: Easing.out(Easing.ease) }), + withTiming(100, { duration: 300, easing: Easing.out(Easing.ease) }) + ) + ); + + const animatedPulseStyle = useAnimatedStyle(() => ({ + ...(ios ? { left: `${pulseStepTranslate?.value}%` } : {}), + opacity: pulseStepOpacity?.value, + })); + + const animatedFinishedStyle = useAnimatedStyle(() => ({ + width: `${finishedStepFill?.value}%`, + })); + + return ( + + + {Array.from({ length: steps }).map((_, index) => { + const stepIndex = index + 1; + const isCurrentStep = stepIndex === currentStep; + const isFinished = currentStep > stepIndex; + const isPulsing = isCurrentStep && !isFinished; + const isAnimatingFill = stepIndex === currentStep - 1 && isFinished; + + return ( + + {isPulsing ? ( + + ) : ( + + )} + + ); + })} + + + ); +}; + +export default magicMemo(StepIndicator, ['steps', 'currentStep']); diff --git a/src/components/svg/AvatarCoverPhotoMaskSvg.tsx b/src/components/svg/AvatarCoverPhotoMaskSvg.tsx new file mode 100644 index 00000000000..6bc63776050 --- /dev/null +++ b/src/components/svg/AvatarCoverPhotoMaskSvg.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import Svg, { Path } from 'react-native-svg'; + +export default function AvatarCoverPhotoMaskSvg({ + backgroundColor, +}: { + backgroundColor?: string; +}) { + return ( + + + + ); +} diff --git a/src/components/text/Emoji.js b/src/components/text/Emoji.js index f82b816ddb3..a9befaeaf79 100644 --- a/src/components/text/Emoji.js +++ b/src/components/text/Emoji.js @@ -23,7 +23,7 @@ function getEmoji(name) { } export default function Emoji({ - children, + children = undefined, letterSpacing = 'zero', lineHeight = 'none', name, diff --git a/src/components/token-family/TokenFamilyHeader.js b/src/components/token-family/TokenFamilyHeader.js index cec433b4ba4..81c21fd3c8a 100644 --- a/src/components/token-family/TokenFamilyHeader.js +++ b/src/components/token-family/TokenFamilyHeader.js @@ -42,7 +42,7 @@ const TitleText = styled(TruncatedText).attrs({ })({ flex: 1, marginBottom: 1, - paddingLeft: ({ isShowcase }) => (isShowcase ? 1 : 10), + paddingLeft: 10, paddingRight: 9, }); @@ -118,7 +118,7 @@ const TokenFamilyHeader = ({ /> )} - {title} + {title} {childrenAmount} diff --git a/src/components/token-family/TokenFamilyHeaderIcon.js b/src/components/token-family/TokenFamilyHeaderIcon.js index 6888f70f09a..627f200122d 100644 --- a/src/components/token-family/TokenFamilyHeaderIcon.js +++ b/src/components/token-family/TokenFamilyHeaderIcon.js @@ -1,22 +1,17 @@ import React, { useMemo } from 'react'; import { Emoji } from '../text'; +import { Box } from '@rainbow-me/design-system'; import { ImgixImage } from '@rainbow-me/images'; -import styled from '@rainbow-me/styled-components'; import { borders } from '@rainbow-me/styles'; import { FallbackIcon, initials } from '@rainbow-me/utils'; import ShadowStack from 'react-native-shadow-stack'; const shadowsFactory = colors => [[0, 3, android ? 5 : 9, colors.shadow, 0.1]]; -const TrophyEmoji = styled(Emoji).attrs({ - align: 'center', - name: 'trophy', - size: 'medium', -})({ - height: 22, - marginRight: 4.5, - textAlignVertical: 'center', -}); +const EMOJI_HEADERS = { + Selling: 'money_with_wings', + Showcase: 'trophy', +}; const TokenFamilyHeaderIcon = ({ familyImage, @@ -32,8 +27,14 @@ const TokenFamilyHeaderIcon = ({ const shadows = useMemo(() => shadowsFactory(colors), [colors]); - return familyName === 'Showcase' ? ( - + return EMOJI_HEADERS[familyName] ? ( + + + ) : ( (align === 'left' ? 'flex-start' : 'flex-end'), borderRadius: 12, height: 24, - marginTop: -17, + marginTop: ({ isENS, isNft }) => (isNft && !isENS ? -10 : -14), overflow: 'hidden', paddingTop: 12, width: 50, @@ -33,10 +35,12 @@ export default function TokenInfoItem({ color, children, enableHapticFeedback = true, + isENS, isNft, onInfoPress, onPress, showDivider, + addonComponent, showInfoButton, size, title, @@ -86,33 +90,57 @@ export default function TokenInfoItem({ {asset ? ( ) : ( - ( + + {children} + {addonComponent} + + )} > - - {!loading && children} - - + + {!loading && children} + + + )} {loading && ( diff --git a/src/components/token-info/TokenInfoValue.js b/src/components/token-info/TokenInfoValue.js index 25186149bd3..bf5db35fde9 100644 --- a/src/components/token-info/TokenInfoValue.js +++ b/src/components/token-info/TokenInfoValue.js @@ -2,9 +2,17 @@ import { TruncatedText } from '../text'; import styled from '@rainbow-me/styled-components'; const TokenInfoValue = styled(TruncatedText).attrs( - ({ color, isNft, theme: { colors }, size, weight = 'semibold' }) => ({ + ({ + color, + isNft, + lineHeight, + size, + theme: { colors }, + weight = 'semibold', + }) => ({ color: color || colors.dark, letterSpacing: isNft ? 'rounded' : 'roundedTight', + lineHeight: lineHeight, size: size || 'larger', weight, }) diff --git a/src/components/unique-token/Tag.js b/src/components/unique-token/Tag.js index e192ee06683..39d0526f201 100644 --- a/src/components/unique-token/Tag.js +++ b/src/components/unique-token/Tag.js @@ -146,7 +146,7 @@ const Tag = ({ } } ); - }, [hideOpenSeaAction, slug, originalValue, title]); + }, [hideOpenSeaAction, isURL, slug, title, originalValue]); const menuConfig = useMemo(() => { const menuItems = []; @@ -163,7 +163,7 @@ const Tag = ({ menuItems, menuTitle: '', }; - }, [hideOpenSeaAction, originalValue]); + }, [hideOpenSeaAction, isURL]); const textWithUpdatedCase = lowercase ? text : upperFirst(text); diff --git a/src/components/unique-token/UniqueTokenCard.js b/src/components/unique-token/UniqueTokenCard.js index c20ce682e65..41696a397eb 100644 --- a/src/components/unique-token/UniqueTokenCard.js +++ b/src/components/unique-token/UniqueTokenCard.js @@ -3,13 +3,13 @@ import { ButtonPressAnimation } from '../animations'; import { InnerBorder } from '../layout'; import { CardSize } from './CardSize'; import UniqueTokenImage from './UniqueTokenImage'; -import isSupportedUriExtension from '@rainbow-me/helpers/isSupportedUriExtension'; import { usePersistentAspectRatio, usePersistentDominantColorFromImage, } from '@rainbow-me/hooks'; import styled from '@rainbow-me/styled-components'; import { shadow as shadowUtil } from '@rainbow-me/styles'; +import isSVGImage from '@rainbow-me/utils/isSVG'; const UniqueTokenCardBorderRadius = 20; const UniqueTokenCardShadowFactory = colors => [0, 2, 6, colors.shadow, 0.08]; @@ -42,7 +42,7 @@ const UniqueTokenCard = ({ usePersistentAspectRatio(item.lowResUrl); usePersistentDominantColorFromImage(item.lowResUrl); - const isSVG = isSupportedUriExtension(item.image_url, ['.svg']); + const isSVG = isSVGImage(item.image_url); const handlePress = useCallback(() => { if (onPress) { diff --git a/src/components/unique-token/UniqueTokenImage.js b/src/components/unique-token/UniqueTokenImage.js index 90aad6d4618..d42d4057b6b 100644 --- a/src/components/unique-token/UniqueTokenImage.js +++ b/src/components/unique-token/UniqueTokenImage.js @@ -1,3 +1,4 @@ +import { toLower } from 'lodash'; import React, { Fragment, useCallback, useState } from 'react'; import { useTheme } from '../../context/ThemeContext'; import { buildUniqueTokenName } from '../../helpers/assets'; @@ -5,10 +6,11 @@ import { Centered } from '../layout'; import RemoteSvg from '../svg/RemoteSvg'; import { Monospace } from '../text'; import svgToPngIfNeeded from '@rainbow-me/handlers/svgs'; -import isSupportedUriExtension from '@rainbow-me/helpers/isSupportedUriExtension'; import { ImgixImage } from '@rainbow-me/images'; +import { ENS_NFT_CONTRACT_ADDRESS } from '@rainbow-me/references'; import styled from '@rainbow-me/styled-components'; import { position } from '@rainbow-me/styles'; +import isSVGImage from '@rainbow-me/utils/isSVG'; const FallbackTextColorVariants = (darkMode, colors) => ({ dark: darkMode @@ -37,7 +39,9 @@ const UniqueTokenImage = ({ size, transformSvgs = true, }) => { - const isSVG = isSupportedUriExtension(imageUrl, ['.svg']); + const isENS = + toLower(item.asset_contract?.address) === toLower(ENS_NFT_CONTRACT_ADDRESS); + const isSVG = isSVGImage(imageUrl); const [error, setError] = useState(null); const handleError = useCallback(error => setError(error), [setError]); const { isDarkMode, colors } = useTheme(); @@ -49,7 +53,7 @@ const UniqueTokenImage = ({ {isSVG && !transformSvgs && !error ? ( > = { action: { - color: colors.appleBlue, - mode: 'darkTinted', + dark: { + color: colors.appleBlueLight, + mode: 'darkTinted', + }, + light: { + color: colors.appleBlue, + mode: 'darkTinted', + }, }, body: { dark: { color: colors.blackTint, mode: 'dark', }, + darkTinted: { + color: colors.blackTint, + mode: 'darkTinted', + }, light: { color: colors.white, mode: 'light', }, + lightTinted: { + color: colors.white, + mode: 'lightTinted', + }, }, swap: { color: colors.swapPurple, @@ -91,11 +109,13 @@ export type ForegroundColor = | 'divider40' | 'divider60' | 'divider80' + | 'divider100' | 'primary' | 'secondary' | 'secondary06' | 'secondary10' | 'secondary20' + | 'secondary25' | 'secondary30' | 'secondary40' | 'secondary50' @@ -109,7 +129,15 @@ export const foregroundColors: Record< ForegroundColor, string | ContextualColorValue > = { - action: colors.appleBlue, + action: { + dark: colors.appleBlueLight, + light: colors.appleBlue, + }, + divider100: { + dark: 'rgba(60, 66, 82, 0.6)', + darkTinted: 'rgba(255, 255, 255, 0.15)', + light: 'rgba(60, 66, 82, 0.12)', + }, divider20: { dark: 'rgba(60, 66, 82, 0.025)', darkTinted: 'rgba(255, 255, 255, 0.01)', @@ -156,6 +184,11 @@ export const foregroundColors: Record< darkTinted: colors.white20, light: colors.grey20, }, + secondary25: { + dark: colors.sky25, + darkTinted: colors.white25, + light: colors.grey25, + }, secondary30: { dark: colors.sky30, darkTinted: colors.white30, @@ -189,8 +222,8 @@ export const foregroundColors: Record< shadow: { dark: colors.black, darkTinted: colors.black, - light: colors.blackTint, - lightTinted: colors.blackTint, + light: colors.greyDark, + lightTinted: colors.greyDark, }, swap: colors.swapPurple, }; @@ -286,6 +319,7 @@ export const dividerColors = selectForegroundColors( 'divider20', 'divider40', 'divider60', - 'divider80' + 'divider80', + 'divider100' ); export type DividerColor = typeof dividerColors[number]; diff --git a/src/design-system/components/Heading/Heading.examples.tsx b/src/design-system/components/Heading/Heading.examples.tsx index 7d30b3aa885..90b8f547ba1 100644 --- a/src/design-system/components/Heading/Heading.examples.tsx +++ b/src/design-system/components/Heading/Heading.examples.tsx @@ -12,7 +12,9 @@ import { Stack } from '../Stack/Stack'; import { Heading, HeadingProps } from './Heading'; const headingExamples: Required>[] = [ + { size: '34px', weight: 'bold' }, { size: '30px', weight: 'bold' }, + { size: '28px', weight: 'bold' }, { size: '23px', weight: 'bold' }, { size: '20px', weight: 'bold' }, { size: '18px', weight: 'bold' }, diff --git a/src/design-system/components/Heading/Heading.tsx b/src/design-system/components/Heading/Heading.tsx index c2c3e62071c..9a5a7d52e60 100644 --- a/src/design-system/components/Heading/Heading.tsx +++ b/src/design-system/components/Heading/Heading.tsx @@ -1,17 +1,23 @@ import React, { ElementRef, forwardRef, ReactNode, useMemo } from 'react'; import { Text as NativeText } from 'react-native'; +import { CustomColor } from '../../color/useForegroundColor'; import { createLineHeightFixNode } from '../../typography/createLineHeightFixNode'; import { nodeHasEmoji, nodeIsString, renderStringWithEmoji, } from '../../typography/renderStringWithEmoji'; -import { headingSizes, headingWeights } from '../../typography/typography'; +import { + headingSizes, + headingWeights, + TextColor, +} from '../../typography/typography'; import { useHeadingStyle } from './useHeadingStyle'; export type HeadingProps = { align?: 'center' | 'left' | 'right'; + color?: TextColor | CustomColor; size?: keyof typeof headingSizes; weight?: keyof typeof headingWeights; numberOfLines?: number; @@ -31,6 +37,7 @@ export const Heading = forwardRef, HeadingProps>( function Heading( { align, + color, numberOfLines, containsEmoji: containsEmojiProp = false, children, @@ -54,7 +61,7 @@ export const Heading = forwardRef, HeadingProps>( } } - const headingStyle = useHeadingStyle({ align, size, weight }); + const headingStyle = useHeadingStyle({ align, color, size, weight }); const lineHeightFixNode = useMemo( () => createLineHeightFixNode(headingStyle.lineHeight), diff --git a/src/design-system/components/Heading/useHeadingStyle.ts b/src/design-system/components/Heading/useHeadingStyle.ts index 90e2a0ebad3..c632e4d3ae0 100644 --- a/src/design-system/components/Heading/useHeadingStyle.ts +++ b/src/design-system/components/Heading/useHeadingStyle.ts @@ -5,10 +5,11 @@ import { HeadingProps } from './Heading'; export function useHeadingStyle({ align: textAlign, + color = 'primary', size = '20px', weight = 'heavy', -}: Pick) { - const colorValue = useForegroundColor('primary'); +}: Pick) { + const colorValue = useForegroundColor(color); const sizeStyles = headingSizes[size]; const weightStyles = headingWeights[weight]; diff --git a/src/design-system/components/Text/Text.examples.tsx b/src/design-system/components/Text/Text.examples.tsx index c4aa5edcaee..b5e7b4fdec1 100644 --- a/src/design-system/components/Text/Text.examples.tsx +++ b/src/design-system/components/Text/Text.examples.tsx @@ -18,7 +18,9 @@ const textExamples: Required>[] = [ { size: '23px', weight: 'bold' }, { size: '18px', weight: 'bold' }, { size: '16px', weight: 'bold' }, + { size: '15px', weight: 'bold' }, { size: '14px', weight: 'bold' }, + { size: '12px', weight: 'bold' }, { size: '11px', weight: 'bold' }, ]; diff --git a/src/design-system/layout/shadow.ts b/src/design-system/layout/shadow.ts index d28ed3bfc8d..176b3d16900 100644 --- a/src/design-system/layout/shadow.ts +++ b/src/design-system/layout/shadow.ts @@ -80,6 +80,19 @@ export const shadowHierarchy = { opacity: 1, }, } as ShadowValue, + '15px light': { + ios: [ + { + offset: { x: 0, y: 5 }, + blur: 15, + opacity: 0.15, + }, + ], + android: { + elevation: 15, + opacity: 0.5, + }, + } as ShadowValue, '21px light': { ios: [ { @@ -121,12 +134,12 @@ export const shadowHierarchy = { { offset: { x: 0, y: 5 }, blur: 15, - opacity: 0.06, + opacity: 0.2, }, { offset: { x: 0, y: 10 }, blur: 30, - opacity: 0.04, + opacity: 0.15, }, ], android: { @@ -139,12 +152,12 @@ export const shadowHierarchy = { { offset: { x: 0, y: 5 }, blur: 15, - opacity: 0.12, + opacity: 0.3, }, { offset: { x: 0, y: 10 }, blur: 30, - opacity: 0.04, + opacity: 0.15, }, ], android: { diff --git a/src/design-system/layout/size.ts b/src/design-system/layout/size.ts index a33fde5de93..3ef1283369d 100644 --- a/src/design-system/layout/size.ts +++ b/src/design-system/layout/size.ts @@ -26,6 +26,7 @@ export const heights = { '3/4': fraction(3, 4), '3/5': fraction(3, 5), '30px': 30, + '36px': 36, '4/5': fraction(4, 5), '40px': 40, '46px': 46, diff --git a/src/design-system/layout/space.ts b/src/design-system/layout/space.ts index 1651ddf65ae..652c34243ac 100644 --- a/src/design-system/layout/space.ts +++ b/src/design-system/layout/space.ts @@ -14,6 +14,7 @@ export const space = { '24px': 24, '30px': 30, '34px': 34, + '36px': 36, '42px': 42, '60px': 60, '72px': 72, @@ -36,6 +37,7 @@ export const negativeSpace = { '-24px': -24, '-30px': -30, '-34px': -34, + '-36px': -36, '-42px': -42, '-60px': -60, '-72px': -72, @@ -61,6 +63,7 @@ const spaceToNegativeSpace: Record< '24px': '-24px', '30px': '-30px', '34px': '-34px', + '36px': '-36px', '42px': '-42px', '60px': '-60px', '72px': '-72px', diff --git a/src/design-system/typography/typeHierarchy.docs.tsx b/src/design-system/typography/typeHierarchy.docs.tsx index 37ab5af45b7..ac1e59e3162 100644 --- a/src/design-system/typography/typeHierarchy.docs.tsx +++ b/src/design-system/typography/typeHierarchy.docs.tsx @@ -25,6 +25,12 @@ const docs: DocsType = { Example: () => source( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit + Lorem ipsum dolor sit amet, consectetur adipiscing elit @@ -51,9 +57,15 @@ const docs: DocsType = { Lorem ipsum dolor sit amet, consectetur adipiscing elit + + Lorem ipsum dolor sit amet, consectetur adipiscing elit + Lorem ipsum dolor sit amet, consectetur adipiscing elit + + Lorem ipsum dolor sit amet, consectetur adipiscing elit + Lorem ipsum dolor sit amet, consectetur adipiscing elit diff --git a/src/design-system/typography/typeHierarchy.ts b/src/design-system/typography/typeHierarchy.ts index 903cf011175..d992355aef2 100644 --- a/src/design-system/typography/typeHierarchy.ts +++ b/src/design-system/typography/typeHierarchy.ts @@ -2,7 +2,7 @@ export const typeHierarchy = { heading: { '18px': { fontSize: 18, - letterSpacing: 0.5, + letterSpacing: 0.6, lineHeight: 21, marginCorrection: { android: 0.2, @@ -36,6 +36,15 @@ export const typeHierarchy = { ios: -0.3, }, }, + '28px': { + fontSize: 28, + letterSpacing: 0, + lineHeight: 33, + marginCorrection: { + android: -0.3, + ios: -0.3, + }, + }, '30px': { fontSize: 30, letterSpacing: 0.6, @@ -45,6 +54,15 @@ export const typeHierarchy = { ios: 0.5, }, }, + '34px': { + fontSize: 34, + letterSpacing: 0.6, + lineHeight: 41, + marginCorrection: { + android: 0, + ios: 0.5, + }, + }, }, text: { @@ -57,18 +75,36 @@ export const typeHierarchy = { ios: -0.3, }, }, + '12px': { + fontSize: 12, + letterSpacing: 0.6, + lineHeight: 14, + marginCorrection: { + android: -0.3, + ios: -0.3, + }, + }, '14px': { fontSize: 14, letterSpacing: 0.6, - lineHeight: 17, + lineHeight: 19, marginCorrection: { android: -0.1, ios: -0.3, }, }, + '15px': { + fontSize: 15, + letterSpacing: 0.6, + lineHeight: 21, + marginCorrection: { + android: 2.4, + ios: -0.5, + }, + }, '16px': { fontSize: 16, - letterSpacing: 0.5, + letterSpacing: 0.6, lineHeight: 22, marginCorrection: { android: 2.4, diff --git a/src/design-system/typography/typography.ts b/src/design-system/typography/typography.ts index 36fc96a4fa8..de9444446eb 100644 --- a/src/design-system/typography/typography.ts +++ b/src/design-system/typography/typography.ts @@ -131,6 +131,7 @@ export const textColors = selectForegroundColors( 'secondary', 'secondary10', 'secondary20', + 'secondary25', 'secondary30', 'secondary40', 'secondary50', diff --git a/src/ens-avatar/README.md b/src/ens-avatar/README.md new file mode 100644 index 00000000000..21e360fe4dc --- /dev/null +++ b/src/ens-avatar/README.md @@ -0,0 +1,88 @@ +# ens-avatar + +> Note: This is a fork of [https://github.com/ensdomains/ens-avatar](https://github.com/ensdomains/ens-avatar) to support React Native + +Avatar resolver library for ~both nodejs and browser~ REACT NATIVE! (a Rainbow fork). + +## Getting started + +### Prerequisites +- Have your web3 provider ready (web3.js, ethers.js) +- [Only for node env] Have jsdom installed. + +And good to go! + +### Installation + +```bash +# npm +npm i @ensdomains/ens-avatar +# yarn +yarn add @ensdomains/ens-avatar +``` + +### Usage + +```js +import { StaticJsonRpcProvider } from '@ethersproject/providers'; +import { AvatarResolver, utils: avtUtils } from '@ensdomains/ens-avatar'; + +// const { JSDOM } = require('jsdom'); on nodejs +// const jsdom = new JSDOM().window; on nodejs + +const provider = new StaticJsonRpcProvider( + ... + ); +... +async function getAvatar() { + const avt = new AvatarResolver(provider); + const avatarURI = await avt.getAvatar('tanrikulu.eth', { /* jsdomWindow: jsdom (on nodejs) */ }); + // avatarURI = https://ipfs.io/ipfs/QmUShgfoZQSHK3TQyuTfUpsc8UfeNfD8KwPUvDBUdZ4nmR +} + +async function getAvatarMetadata() { + const avt = new AvatarResolver(provider); + const avatarMetadata = await avt.getMetadata('tanrikulu.eth'); + // avatarMetadata = { image: ... , uri: ... , name: ... , description: ... } + const avatarURI = avtUtils.getImageURI({ metadata /*, jsdomWindow: jsdom (on nodejs) */ }); + // avatarURI = https://ipfs.io/ipfs/QmUShgfoZQSHK3TQyuTfUpsc8UfeNfD8KwPUvDBUdZ4nmR +} +``` + +## Supported avatar specs + +### NFTs +- ERC721 +- ERC1155 + +### URIs +- HTTP +- Base64 +- IPFS + +## Options + +### Cache _(Default: Disabled)_ +```js +const avt = new AvatarResolver(provider, { ttl: 300 }); // 5 min response cache in memory +``` + +### Custom IPFS Gateway _(Default: https://ipfs.io)_ +```js +const avt = new AvatarResolver(provider, { ipfs: 'https://dweb.link' }); +``` + +## Demo +- Create .env file with INFURA_KEY env variable +- Build the library + +- Node example +```bash +node example/node.js ENS_NAME +``` + +- Browser example +```bash +yarn build:demo +http-server example +``` diff --git a/src/ens-avatar/src/index.ts b/src/ens-avatar/src/index.ts new file mode 100644 index 00000000000..f230c95cab2 --- /dev/null +++ b/src/ens-avatar/src/index.ts @@ -0,0 +1,107 @@ +import { BaseProvider } from '@ethersproject/providers'; +import ERC1155 from './specs/erc1155'; +import ERC721 from './specs/erc721'; +import URI from './specs/uri'; +import { getImageURI, parseNFT } from './utils'; + +export interface Spec { + getMetadata: ( + provider: BaseProvider, + ownerAddress: string | undefined, + contractAddress: string, + tokenID: string, + opts?: AvatarRequestOpts + ) => Promise; +} + +export const specs: { [key: string]: new () => Spec } = Object.freeze({ + erc1155: ERC1155, + erc721: ERC721, +}); + +export interface AvatarRequestOpts { + allowNonOwnerNFTs?: boolean; + type?: 'avatar' | 'cover'; +} + +interface AvatarResolverOpts { + ipfs?: string; +} + +export interface IAvatarResolver { + provider: BaseProvider; + options?: AvatarResolverOpts; + getImage(ens: string, data?: AvatarRequestOpts): Promise; + getMetadata(ens: string, data?: AvatarRequestOpts): Promise; +} + +export class AvatarResolver implements IAvatarResolver { + provider: BaseProvider; + options?: AvatarResolverOpts; + + constructor(provider: BaseProvider, options?: AvatarResolverOpts) { + this.provider = provider; + this.options = options; + } + + async getMetadata(ens: string, opts?: AvatarRequestOpts) { + // retrieve registrar address and resolver object from ens name + const [resolvedAddress, resolver] = await Promise.all([ + this.provider.resolveName(ens), + this.provider.getResolver(ens), + ]); + if (!resolvedAddress || !resolver) return null; + + // retrieve 'avatar' text recored from resolver + const avatarURI = await resolver.getText(opts?.type || 'avatar'); + if (!avatarURI) return null; + + // test case-insensitive in case of uppercase records + if (!/\/erc1155:|\/erc721:/i.test(avatarURI)) { + const uriSpec = new URI(); + const metadata = uriSpec.getMetadata(avatarURI); + return { uri: ens, ...metadata }; + } + + // parse retrieved avatar uri + const { chainID, namespace, contractAddress, tokenID } = parseNFT( + avatarURI + ); + // detect avatar spec by namespace + const spec = new specs[namespace](); + if (!spec) return null; + + // add meta information of the avatar record + const host_meta = { + chain_id: chainID, + contract_address: contractAddress, + namespace, + reference_url: `https://opensea.io/assets/${contractAddress}/${tokenID}`, + token_id: tokenID, + }; + + // retrieve metadata + const metadata = await spec.getMetadata( + this.provider, + resolvedAddress, + contractAddress, + tokenID, + opts + ); + return { host_meta, uri: ens, ...metadata }; + } + + async getImage( + ens: string, + opts?: AvatarRequestOpts + ): Promise { + const metadata = await this.getMetadata(ens, opts); + if (!metadata) return null; + return getImageURI({ + customGateway: this.options?.ipfs, + metadata, + }); + } +} + +export const utils = { getImageURI, parseNFT }; diff --git a/src/ens-avatar/src/specs/erc1155.ts b/src/ens-avatar/src/specs/erc1155.ts new file mode 100644 index 00000000000..3fce23d5085 --- /dev/null +++ b/src/ens-avatar/src/specs/erc1155.ts @@ -0,0 +1,46 @@ +import { Buffer } from 'buffer'; +import { Contract } from '@ethersproject/contracts'; +import { BaseProvider } from '@ethersproject/providers'; +import { AvatarRequestOpts } from '..'; +import { resolveURI } from '../utils'; +import { apiGetUniqueTokenImage } from '@rainbow-me/handlers/opensea-api'; + +const abi = [ + 'function uri(uint256 _id) public view returns (string memory)', + 'function balanceOf(address account, uint256 id) public view returns (uint256)', +]; + +export default class ERC1155 { + async getMetadata( + provider: BaseProvider, + ownerAddress: string | undefined, + contractAddress: string, + tokenID: string, + opts?: AvatarRequestOpts + ) { + const contract = new Contract(contractAddress, abi, provider); + const [tokenURI, balance] = await Promise.all([ + contract.uri(tokenID), + ownerAddress && contract.balanceOf(ownerAddress, tokenID), + ]); + if (!opts?.allowNonOwnerNFTs && ownerAddress && balance.eq(0)) return null; + + const { uri: resolvedURI, isOnChain, isEncoded } = resolveURI(tokenURI); + let _resolvedUri = resolvedURI; + if (isOnChain) { + if (isEncoded) { + _resolvedUri = Buffer.from( + resolvedURI.replace('data:application/json;base64,', ''), + 'base64' + ).toString(); + } + return JSON.parse(_resolvedUri); + } + + const { image_url } = await apiGetUniqueTokenImage( + contractAddress, + tokenID + ); + return { image: image_url }; + } +} diff --git a/src/ens-avatar/src/specs/erc721.ts b/src/ens-avatar/src/specs/erc721.ts new file mode 100644 index 00000000000..e167bdfa68b --- /dev/null +++ b/src/ens-avatar/src/specs/erc721.ts @@ -0,0 +1,57 @@ +import { Buffer } from 'buffer'; +import { Contract } from '@ethersproject/contracts'; +import { BaseProvider } from '@ethersproject/providers'; +import { AvatarRequestOpts } from '..'; +import { resolveURI } from '../utils'; +import { apiGetUniqueTokenImage } from '@rainbow-me/handlers/opensea-api'; +import { getNFTByTokenId } from '@rainbow-me/handlers/simplehash'; + +const abi = [ + 'function tokenURI(uint256 tokenId) external view returns (string memory)', + 'function ownerOf(uint256 tokenId) public view returns (address)', +]; + +export default class ERC721 { + async getMetadata( + provider: BaseProvider, + ownerAddress: string | undefined, + contractAddress: string, + tokenID: string, + opts?: AvatarRequestOpts + ) { + const contract = new Contract(contractAddress, abi, provider); + const [tokenURI, owner] = await Promise.all([ + contract.tokenURI(tokenID), + ownerAddress && contract.ownerOf(tokenID), + ]); + if ( + !opts?.allowNonOwnerNFTs && + ownerAddress && + owner.toLowerCase() !== ownerAddress.toLowerCase() + ) { + return null; + } + + const { uri: resolvedURI, isOnChain, isEncoded } = resolveURI(tokenURI); + let _resolvedUri = resolvedURI; + if (isOnChain) { + if (isEncoded) { + _resolvedUri = Buffer.from( + resolvedURI.replace('data:application/json;base64,', ''), + 'base64' + ).toString(); + } + return JSON.parse(_resolvedUri); + } + + let image; + try { + const data = await apiGetUniqueTokenImage(contractAddress, tokenID); + image = data?.image_url; + } catch (error) { + const data = await getNFTByTokenId({ contractAddress, tokenId: tokenID }); + image = data?.previews?.image_medium_url; + } + return { image }; + } +} diff --git a/src/ens-avatar/src/specs/uri.ts b/src/ens-avatar/src/specs/uri.ts new file mode 100644 index 00000000000..e7af75b4145 --- /dev/null +++ b/src/ens-avatar/src/specs/uri.ts @@ -0,0 +1,8 @@ +import { resolveURI } from '../utils'; + +export default class URI { + getMetadata(uri: string) { + const { uri: resolvedURI } = resolveURI(uri); + return { image: resolvedURI }; + } +} diff --git a/src/ens-avatar/src/utils.ts b/src/ens-avatar/src/utils.ts new file mode 100644 index 00000000000..8c9428b4a26 --- /dev/null +++ b/src/ens-avatar/src/utils.ts @@ -0,0 +1,132 @@ +import { CID } from 'multiformats/cid'; +import urlJoin from 'url-join'; + +const IPFS_SUBPATH = '/ipfs/'; +const IPNS_SUBPATH = '/ipns/'; +const ipfsRegex = /(ipfs:\/|ipns:\/)?(\/)?(ipfs\/|ipns\/)?([\w\-.]+)(\/.*)?/; +const base64Regex = /^data:([a-zA-Z\-/+]*);base64,([^"].*)/; +const dataURIRegex = /^data:([a-zA-Z\-/+]*)?(;[a-zA-Z0-9].*)?(,)/; + +export class BaseError extends Error { + __proto__: Error; + constructor(message?: string) { + const trueProto = new.target.prototype; + super(message); + + this.__proto__ = trueProto; + } +} + +// simple assert without nested check +function assert(condition: any, message: string) { + if (!condition) { + throw message; + } +} + +export class NFTURIParsingError extends BaseError {} + +export function isCID(hash: any) { + // check if given string or object is a valid IPFS CID + try { + if (typeof hash === 'string') { + return Boolean(CID.parse(hash)); + } + + return Boolean(CID.asCID(hash)); + } catch (_error) { + return false; + } +} + +export function parseNFT(uri: string, seperator: string = '/') { + // parse valid nft spec (CAIP-22/CAIP-29) + // @see: https://github.com/ChainAgnostic/CAIPs/tree/master/CAIPs + try { + assert(uri, 'parameter URI cannot be empty'); + + if (uri.startsWith('did:nft:')) { + // convert DID to CAIP + uri = uri.replace('did:nft:', '').replace(/_/g, '/'); + } + + const [reference, asset_namespace, tokenID] = uri.split(seperator); + const [, chainID] = reference.split(':'); + const [namespace, contractAddress] = asset_namespace.split(':'); + + assert(chainID, 'chainID not found'); + assert(contractAddress, 'contractAddress not found'); + assert(namespace, 'namespace not found'); + assert(tokenID, 'tokenID not found'); + + return { + chainID: Number(chainID), + contractAddress, + namespace: namespace.toLowerCase(), + tokenID, + }; + } catch (error) { + throw new NFTURIParsingError(`${(error as Error).message} - ${uri}`); + } +} + +export function resolveURI( + uri: string, + customGateway?: string +): { uri: string; isOnChain: boolean; isEncoded: boolean } { + // resolves uri based on its' protocol + const isEncoded = base64Regex.test(uri); + if (isEncoded || uri.startsWith('http')) { + return { isEncoded, isOnChain: isEncoded, uri }; + } + + const ipfsGateway = customGateway || 'https://cloudflare-ipfs.com'; + const ipfsRegexpResult = uri.match(ipfsRegex); + const matches = ipfsRegexpResult || []; + const protocol = matches?.[1] || ''; + const subpath = matches?.[3] || ''; + const target = matches?.[4] || ''; + const subtarget = matches?.[5] || ''; + if ((protocol === 'ipns:/' || subpath === 'ipns/') && target) { + return { + isEncoded: false, + isOnChain: false, + uri: urlJoin(ipfsGateway, IPNS_SUBPATH, target, subtarget), + }; + } else if (isCID(target)) { + // Assume that it's a regular IPFS CID and not an IPNS key + return { + isEncoded: false, + isOnChain: false, + uri: urlJoin(ipfsGateway, IPFS_SUBPATH, target, subtarget), + }; + } else { + // we may want to throw error here + return { + isEncoded: false, + isOnChain: true, + uri: uri.replace(dataURIRegex, ''), + }; + } +} + +export interface ImageURIOpts { + metadata: any; + customGateway?: string; + jsdomWindow?: any; +} + +export function getImageURI({ metadata, customGateway }: ImageURIOpts) { + // retrieves image uri from metadata, if image is onchain then convert to base64 + const { image, image_url, image_data } = metadata; + + const _image = image || image_url || image_data; + if (!_image) return null; + + const { uri: parsedURI } = resolveURI(_image, customGateway); + + if (parsedURI.startsWith('data:') || parsedURI.startsWith('http')) { + return parsedURI; + } + return null; +} diff --git a/src/entities/ensRegistration.ts b/src/entities/ensRegistration.ts index f07eea30f9a..4e59f486daa 100644 --- a/src/entities/ensRegistration.ts +++ b/src/entities/ensRegistration.ts @@ -1,23 +1,42 @@ import { EthereumAddress } from '.'; -import { ENS_RECORDS } from '@rainbow-me/helpers/ens'; +import { ENS_RECORDS, REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; -export type Records = { [key in keyof typeof ENS_RECORDS]: string }; +export type Records = { [key in ENS_RECORDS]?: string }; -export interface RegistrationParameters { - name: string; +export interface ENSRegistrationRecords { + coinAddress: { key: string; address: string }[] | null; + contentHash: string | null; + ensAssociatedAddress: string | null; + text: { key: string; value: string }[] | null; +} + +export interface TransactionRegistrationParameters { + commitTransactionHash?: string; + commitTransactionConfirmedAt?: number; + registerTransactionHash?: number; +} + +export interface RegistrationParameters + extends TransactionRegistrationParameters { duration: number; - records: Records; + mode?: keyof typeof REGISTRATION_MODES; + name: string; ownerAddress: EthereumAddress; rentPrice: string; - salt?: string; + records?: Records; + initialRecords?: Records; + changedRecords?: Records; + salt: string; setReverseRecord?: boolean; } +export interface ENSRegistrations { + [key: EthereumAddress]: { + [ensName: string]: RegistrationParameters; + }; +} + export interface ENSRegistrationState { currentRegistrationName: string; - registrations: { - [key: EthereumAddress]: { - [ensName: string]: RegistrationParameters; - }; - }; + registrations: ENSRegistrations; } diff --git a/src/entities/index.ts b/src/entities/index.ts index b8cf4a515a5..a1eddeba99a 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -66,7 +66,10 @@ export type { export type { UniswapFavoriteTokenData } from './uniswap'; export type { UniswapPoolData } from './dispersion'; export type { - Records, + ENSRegistrationRecords, + ENSRegistrations, ENSRegistrationState, + Records, RegistrationParameters, + TransactionRegistrationParameters, } from './ensRegistration'; diff --git a/src/handlers/deeplinks.ts b/src/handlers/deeplinks.ts index 3698265b501..05fb7656886 100644 --- a/src/handlers/deeplinks.ts +++ b/src/handlers/deeplinks.ts @@ -1,6 +1,5 @@ import { captureException } from '@sentry/react-native'; import { toLower } from 'lodash'; -// @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'qs'.... Remove this comment to see the full error message import qs from 'qs'; import { Alert } from 'react-native'; import URL from 'url-parse'; @@ -11,6 +10,8 @@ import { walletConnectRemovePendingRedirect, walletConnectSetPendingRedirect, } from '../redux/walletconnect'; +import { defaultConfig } from '@rainbow-me/config/experimental'; +import { PROFILES } from '@rainbow-me/config/experimentalHooks'; import { setDeploymentKey } from '@rainbow-me/handlers/fedora'; import { delay } from '@rainbow-me/helpers/utilities'; import { checkIsValidAddressOrDomain } from '@rainbow-me/helpers/validators'; @@ -50,7 +51,7 @@ export default async function handleDeeplink( const { dispatch } = store; // @ts-expect-error ts-migrate(2722) FIXME: Cannot invoke an object which is possibly 'undefin... Remove this comment to see the full error message const { addr } = qs.parse(urlObj.query?.substring(1)); - const address = toLower(addr); + const address = toLower(addr as string); if (address && address.length > 0) { // @ts-expect-error FIXME: Property 'assets' does not exist on type... const { assets: allAssets, genericAssets } = store.getState().data; @@ -116,9 +117,14 @@ export default async function handleDeeplink( if (addressOrENS) { const isValid = await checkIsValidAddressOrDomain(addressOrENS); if (isValid) { - return Navigation.handleAction(Routes.SHOWCASE_SHEET, { - address: addressOrENS, - }); + const profilesEnabled = defaultConfig?.[PROFILES]?.value; + return Navigation.handleAction( + profilesEnabled ? Routes.PROFILE_SHEET : Routes.SHOWCASE_SHEET, + { + address: addressOrENS, + fromRoute: 'Deeplink', + } + ); } else { const error = new Error('Invalid deeplink: ' + url); captureException(error); diff --git a/src/handlers/ens.ts b/src/handlers/ens.ts index 415ec4817bf..a9b937bf559 100644 --- a/src/handlers/ens.ts +++ b/src/handlers/ens.ts @@ -1,62 +1,269 @@ +import { formatsByCoinType, formatsByName } from '@ensdomains/address-encoder'; +import { Resolver } from '@ethersproject/providers'; +import { captureException } from '@sentry/react-native'; +import { Duration, sub } from 'date-fns'; +import { isZeroAddress } from 'ethereumjs-util'; +import { BigNumber } from 'ethers'; import { debounce, isEmpty, sortBy } from 'lodash'; import { ensClient } from '../apollo/client'; import { + ENS_ACCOUNT_REGISTRATIONS, + ENS_ALL_ACCOUNT_REGISTRATIONS, ENS_DOMAINS, + ENS_GET_COIN_TYPES, + ENS_GET_NAME_FROM_LABELHASH, + ENS_GET_RECORDS, + ENS_GET_REGISTRATION, ENS_REGISTRATIONS, ENS_SUGGESTIONS, + EnsAccountRegistratonsData, + EnsGetCoinTypesData, + EnsGetNameFromLabelhash, + EnsGetRecordsData, + EnsGetRegistrationData, } from '../apollo/queries'; -import { estimateGasWithPadding } from './web3'; +import { ensProfileImagesQueryKey } from '../hooks/useENSProfileImages'; +import { ENSActionParameters } from '../raps/common'; +import { estimateGasWithPadding, getProviderForNetwork } from './web3'; import { ENSRegistrationRecords, + Records, + UniqueAsset, +} from '@rainbow-me/entities'; +import { + ENS_DOMAIN, + ENS_RECORDS, ENSRegistrationTransactionType, generateSalt, getENSExecutionDetails, + getNameOwner, } from '@rainbow-me/helpers/ens'; import { add } from '@rainbow-me/helpers/utilities'; -import { ethUnits } from '@rainbow-me/references'; -import { profileUtils } from '@rainbow-me/utils'; +import { ImgixImage } from '@rainbow-me/images'; +import { handleAndSignImages } from '@rainbow-me/parsers'; +import { queryClient } from '@rainbow-me/react-query/queryClient'; +import { + ENS_NFT_CONTRACT_ADDRESS, + ensPublicResolverAddress, + ethUnits, +} from '@rainbow-me/references'; +import { labelhash, logger, profileUtils } from '@rainbow-me/utils'; +import { AvatarResolver } from 'ens-avatar'; + +const DUMMY_RECORDS = { + 'cover': + 'https://cloudflare-ipfs.com/ipfs/QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/I/m/Vincent_van_Gogh_-_Self-Portrait_-_Google_Art_Project_(454045).jpg', + 'description': 'description', + 'me.rainbow.displayName': 'name', +}; + +const buildEnsToken = ({ + contractAddress, + tokenId, + name, + imageUrl: imageUrl_, +}: { + contractAddress: string; + tokenId: string; + name: string; + imageUrl: string; +}) => { + // @ts-expect-error JavaScript function + const { imageUrl, lowResUrl } = handleAndSignImages(imageUrl_); + return { + animation_url: null, + asset_contract: { + address: contractAddress, + name: 'ENS', + nft_version: '3.0', + schema_name: 'ERC721', + symbol: 'ENS', + total_supply: null, + }, + background: null, + collection: { + description: + 'Ethereum Name Service (ENS) domains are secure domain names for the decentralized world. ENS domains provide a way for users to map human readable names to blockchain and non-blockchain resources, like Ethereum addresses, IPFS hashes, or website URLs. ENS domains can be bought and sold on secondary markets.', + discord_url: null, + external_url: 'https://ens.domains', + featured_image_url: + 'https://lh3.googleusercontent.com/BBj09xD7R4bBtg1lgnAAS9_TfoYXKwMtudlk-0fVljlURaK7BWcARCpkM-1LGNGTAcsGO6V1TgrtmQFvCo8uVYW_QEfASK-9j6Nr=s300', + hidden: false, + image_url: + 'https://lh3.googleusercontent.com/0cOqWoYA7xL9CkUjGlxsjreSYBdrUBE0c6EO1COG4XE8UeP-Z30ckqUNiL872zHQHQU5MUNMNhfDpyXIP17hRSC5HQ=s60', + name: 'ENS: Ethereum Name Service', + short_description: null, + slug: 'ens', + twitter_username: 'ensdomains', + }, + currentPrice: null, + description: `\`${name}\`, an ENS name.`, + external_link: `https://app.ens.domains/search/${name}`, + familyImage: + 'https://lh3.googleusercontent.com/0cOqWoYA7xL9CkUjGlxsjreSYBdrUBE0c6EO1COG4XE8UeP-Z30ckqUNiL872zHQHQU5MUNMNhfDpyXIP17hRSC5HQ=s60', + familyName: 'ENS', + id: tokenId, + image_original_url: imageUrl, + image_url: imageUrl, + isSendable: true, + last_sale: null, + lastPrice: null, + lastPriceUsd: null, + lastSale: undefined, + lastSalePaymentToken: null, + lowResUrl, + name, + permalink: '', + sell_orders: [], + traits: [], + type: 'nft', + uniqueId: name, + urlSuffixForAsset: `${contractAddress}/${tokenId}`, + } as UniqueAsset; +}; + +export const isUnknownOpenSeaENS = (asset?: any) => + asset?.description?.includes('This is an unknown ENS name with the hash') || + !asset?.uniqueId?.includes('.eth') || + !asset?.image_url || + false; + +export const fetchMetadata = async ({ + contractAddress = ENS_NFT_CONTRACT_ADDRESS, + tokenId, +}: { + contractAddress?: string; + tokenId: string; +}) => { + try { + const { data } = await ensClient.query({ + query: ENS_GET_NAME_FROM_LABELHASH, + variables: { + labelhash: BigNumber.from(tokenId).toHexString(), + }, + }); + const name = data.domains[0].labelName; + const image_url = `https://metadata.ens.domains/mainnet/${contractAddress}/${tokenId}/image`; + return { image_url, name: `${name}.eth` }; + } catch (error) { + logger.sentry('ENS: Error getting ENS metadata', error); + captureException(new Error('ENS: Error getting ENS metadata')); + throw error; + } +}; + +export const fetchEnsTokens = async ({ + address, + contractAddress = ENS_NFT_CONTRACT_ADDRESS, + timeAgo, +}: { + address: string; + contractAddress?: string; + timeAgo: Duration; +}) => { + try { + const { data } = await ensClient.query({ + query: ENS_ACCOUNT_REGISTRATIONS, + variables: { + address: address.toLowerCase(), + registrationDate_gt: Math.floor( + sub(new Date(), timeAgo).getTime() / 1000 + ).toString(), + }, + }); + return data.account.registrations.map(registration => { + const tokenId = BigNumber.from(registration.domain.labelhash).toString(); + const token = buildEnsToken({ + contractAddress, + imageUrl: `https://metadata.ens.domains/mainnet/${contractAddress}/${tokenId}/image`, + name: registration.domain.name, + tokenId, + }); + return token; + }); + } catch (error) { + logger.sentry('ENS: Error getting ENS unique tokens', error); + captureException(new Error('ENS: Error getting ENS unique tokens')); + return []; + } +}; export const fetchSuggestions = async ( recipient: any, setSuggestions: any, - setIsFetching = (_unused: any) => {} + setIsFetching = (_unused: any) => {}, + profilesEnabled = false ) => { if (recipient.length > 2) { - let suggestions = []; + let suggestions: { + address: any; + color: number | null; + ens: boolean; + image: any; + network: string; + nickname: any; + uniqueId: any; + }[] = []; setIsFetching(true); const recpt = recipient.toLowerCase(); let result = await ensClient.query({ query: ENS_SUGGESTIONS, variables: { - amount: 75, + amount: 8, name: recpt, }, }); - if (!isEmpty(result?.data?.domains)) { - const ensSuggestions = result.data.domains + const domains = await Promise.all( + result?.data?.domains + .filter( + (domain: { owner: { id: string } }) => + !isZeroAddress(domain.owner.id) + ) + .map( + async (domain: { + name: string; + resolver: { texts: string[] }; + owner: { id: string }; + }) => { + const hasAvatar = domain?.resolver?.texts?.find( + text => text === ENS_RECORDS.avatar + ); + if (!!hasAvatar && profilesEnabled) { + try { + const images = await fetchImages(domain.name); + queryClient.setQueryData( + ensProfileImagesQueryKey(domain.name), + images + ); + return { + ...domain, + avatar: images.avatarUrl, + }; + // eslint-disable-next-line no-empty + } catch (e) {} + } + return domain; + } + ) + ); + const ensSuggestions = domains .map((ensDomain: any) => ({ address: ensDomain?.resolver?.addr?.id || ensDomain?.name, - color: profileUtils.addressHashedColorIndex( ensDomain?.resolver?.addr?.id || ensDomain.name ), - ens: true, + image: ensDomain?.avatar, network: 'mainnet', nickname: ensDomain?.name, uniqueId: ensDomain?.resolver?.addr?.id || ensDomain.name, })) .filter((domain: any) => !domain?.nickname?.includes?.('[')); - const sortedEnsSuggestions = sortBy( - ensSuggestions, - domain => domain.nickname.length, - ['asc'] - ); - - suggestions = sortedEnsSuggestions.slice(0, 3); + suggestions = sortBy(ensSuggestions, domain => domain.nickname.length, [ + 'asc', + ]); } - setSuggestions(suggestions); setIsFetching(false); @@ -66,7 +273,7 @@ export const fetchSuggestions = async ( export const debouncedFetchSuggestions = debounce(fetchSuggestions, 200); -export const fetchRegistrationDate = async (recipient: any) => { +export const fetchRegistrationDate = async (recipient: string) => { if (recipient.length > 2) { const recpt = recipient.toLowerCase(); const result = await ensClient.query({ @@ -91,29 +298,262 @@ export const fetchRegistrationDate = async (recipient: any) => { } }; -export const estimateENSRegisterWithConfigGasLimit = async ({ +export const fetchAccountRegistrations = async (address: string) => { + const registrations = await ensClient.query({ + query: ENS_ALL_ACCOUNT_REGISTRATIONS, + variables: { + address: address?.toLowerCase(), + }, + }); + return registrations; +}; + +export const fetchImages = async (ensName: string) => { + let avatarUrl; + let coverUrl; + const provider = await getProviderForNetwork(); + try { + const avatarResolver = new AvatarResolver(provider); + [avatarUrl, coverUrl] = await Promise.all([ + avatarResolver.getImage(ensName, { + allowNonOwnerNFTs: true, + type: 'avatar', + }), + avatarResolver.getImage(ensName, { + allowNonOwnerNFTs: true, + type: 'cover', + }), + ]); + ImgixImage.preload([ + ...(avatarUrl ? [{ uri: avatarUrl }] : []), + ...(coverUrl ? [{ uri: coverUrl }] : []), + ]); + // eslint-disable-next-line no-empty + } catch (err) {} + + return { + avatarUrl, + coverUrl, + }; +}; + +export const fetchRecords = async (ensName: string) => { + const response = await ensClient.query({ + query: ENS_GET_RECORDS, + variables: { + name: ensName, + }, + }); + const data = response.data?.domains[0] || {}; + + const provider = await getProviderForNetwork(); + const resolver = await provider.getResolver(ensName); + const supportedRecords = Object.values(ENS_RECORDS); + const rawRecordKeys: string[] = data.resolver?.texts || []; + const recordKeys = (rawRecordKeys as ENS_RECORDS[]).filter(key => + supportedRecords.includes(key) + ); + const recordValues = await Promise.all( + recordKeys.map((key: string) => resolver?.getText(key)) + ); + const records = recordKeys.reduce((records, key, i) => { + return { + ...records, + ...(recordValues[i] ? { [key]: recordValues[i] } : {}), + }; + }, {}) as Partial; + + return records; +}; + +export const fetchCoinAddresses = async ( + ensName: string +): Promise<{ [key in ENS_RECORDS]: string }> => { + const response = await ensClient.query({ + query: ENS_GET_COIN_TYPES, + variables: { + name: ensName, + }, + }); + const data = response.data?.domains[0] || {}; + const supportedRecords = Object.values(ENS_RECORDS); + const provider = await getProviderForNetwork(); + const resolver = await provider.getResolver(ensName); + const rawCoinTypes: number[] = data.resolver?.coinTypes || []; + const rawCoinTypesNames: string[] = rawCoinTypes.map( + type => formatsByCoinType[type].name + ); + const coinTypes: number[] = + (rawCoinTypesNames as ENS_RECORDS[]) + .filter(name => supportedRecords.includes(name)) + .map(name => formatsByName[name].coinType) || []; + + const coinAddressValues = await Promise.all( + coinTypes + .map(async (coinType: number) => { + try { + return await resolver?.getAddress(coinType); + } catch (err) { + return undefined; + } + }) + .filter(Boolean) + ); + const coinAddresses: { [key in ENS_RECORDS]: string } = coinTypes.reduce( + (coinAddresses, coinType, i) => { + return { + ...coinAddresses, + ...(coinAddressValues[i] + ? { [formatsByCoinType[coinType].name]: coinAddressValues[i] } + : {}), + }; + }, + {} as { [key in ENS_RECORDS]: string } + ); + return coinAddresses; +}; + +export const fetchOwner = async (ensName: string) => { + const ownerAddress = await getNameOwner(ensName); + + let owner: { address?: string; name?: string } = {}; + if (ownerAddress) { + const name = await fetchReverseRecord(ownerAddress); + owner = { + address: ownerAddress, + name, + }; + } + + return owner; +}; + +export const fetchRegistration = async (ensName: string) => { + const response = await ensClient.query({ + query: ENS_GET_REGISTRATION, + variables: { + id: labelhash(ensName.replace(ENS_DOMAIN, '')), + }, + }); + const data = response.data?.registration || {}; + + let registrant: { address?: string; name?: string } = {}; + if (data.registrant?.id) { + const registrantAddress = data.registrant?.id; + const name = await fetchReverseRecord(registrantAddress); + registrant = { + address: registrantAddress, + name, + }; + } + + return { + registrant, + registration: { + expiryDate: data?.expiryDate, + registrationDate: data?.registrationDate, + }, + }; +}; + +export const fetchPrimary = async (ensName: string) => { + const provider = await getProviderForNetwork(); + const address = await provider.resolveName(ensName); + return { + address, + }; +}; + +export const fetchAccountPrimary = async (accountAddress: string) => { + const ensName = await fetchReverseRecord(accountAddress); + return { + ensName, + }; +}; + +export const fetchProfile = async (ensName: string) => { + const [ + resolver, + records, + coinAddresses, + images, + owner, + { registrant, registration }, + primary, + ] = await Promise.all([ + fetchResolver(ensName), + fetchRecords(ensName), + fetchCoinAddresses(ensName), + fetchImages(ensName), + fetchOwner(ensName), + fetchRegistration(ensName), + fetchPrimary(ensName), + ]); + + const resolverData = { + address: resolver?.address, + type: resolver?.address === ensPublicResolverAddress ? 'default' : 'custom', + }; + + return { + coinAddresses, + images, + owner, + primary, + records, + registrant, + registration, + resolver: resolverData, + }; +}; + +export const fetchProfileRecords = async (ensName: string) => { + const [records, coinAddresses, images] = await Promise.all([ + fetchRecords(ensName), + fetchCoinAddresses(ensName), + fetchImages(ensName), + ]); + + return { + coinAddresses, + images, + records, + }; +}; + +export const estimateENSCommitGasLimit = async ({ name, ownerAddress, duration, rentPrice, salt, -}: { - name: string; - ownerAddress: string; - duration: number; - rentPrice: string; - salt: string; -}) => +}: ENSActionParameters) => estimateENSTransactionGasLimit({ duration, name, ownerAddress, rentPrice, salt, - type: ENSRegistrationTransactionType.REGISTER_WITH_CONFIG, + type: ENSRegistrationTransactionType.COMMIT, }); -export const estimateENSCommitGasLimit = async ({ +export const estimateENSMulticallGasLimit = async ({ + name, + records, + ownerAddress, +}: { + name: string; + records: ENSRegistrationRecords; + ownerAddress?: string; +}) => + estimateENSTransactionGasLimit({ + name, + ownerAddress, + records, + type: ENSRegistrationTransactionType.MULTICALL, + }); + +export const estimateENSRegisterWithConfigGasLimit = async ({ name, ownerAddress, duration, @@ -132,32 +572,23 @@ export const estimateENSCommitGasLimit = async ({ ownerAddress, rentPrice, salt, - type: ENSRegistrationTransactionType.COMMIT, + type: ENSRegistrationTransactionType.REGISTER_WITH_CONFIG, }); -export const estimateENSSetTextGasLimit = async ({ +export const estimateENSRenewGasLimit = async ({ name, - recordKey, - recordValue, + duration, + rentPrice, }: { name: string; - recordKey: string; - recordValue: string; + duration: number; + rentPrice: string; }) => estimateENSTransactionGasLimit({ + duration, name, - records: { - coinAddress: null, - contentHash: null, - ensAssociatedAddress: null, - text: [ - { - key: recordKey, - value: recordValue, - }, - ], - }, - type: ENSRegistrationTransactionType.SET_TEXT, + rentPrice, + type: ENSRegistrationTransactionType.RENEW, }); export const estimateENSSetNameGasLimit = async ({ @@ -173,17 +604,20 @@ export const estimateENSSetNameGasLimit = async ({ type: ENSRegistrationTransactionType.SET_NAME, }); -export const estimateENSMulticallGasLimit = async ({ +export const estimateENSSetTextGasLimit = async ({ name, records, + ownerAddress, }: { name: string; + ownerAddress?: string; records: ENSRegistrationRecords; }) => estimateENSTransactionGasLimit({ name, + ownerAddress, records, - type: ENSRegistrationTransactionType.MULTICALL, + type: ENSRegistrationTransactionType.SET_TEXT, }); export const estimateENSTransactionGasLimit = async ({ @@ -212,10 +646,12 @@ export const estimateENSTransactionGasLimit = async ({ salt, type, }); + const txPayload = { ...(ownerAddress ? { from: ownerAddress } : {}), ...(value ? { value } : {}), }; + const gasLimit = await estimateGasWithPadding( txPayload, contract?.estimateGas[type], @@ -228,7 +664,8 @@ export const estimateENSRegistrationGasLimit = async ( name: string, ownerAddress: string, duration: number, - rentPrice: string + rentPrice: string, + records: Records = DUMMY_RECORDS ) => { const salt = generateSalt(); const commitGasLimitPromise = estimateENSCommitGasLimit({ @@ -238,38 +675,27 @@ export const estimateENSRegistrationGasLimit = async ( rentPrice, salt, }); + + const setRecordsGasLimitPromise = estimateENSSetRecordsGasLimit({ + name: name + ENS_DOMAIN, + records, + }); + const setNameGasLimitPromise = estimateENSSetNameGasLimit({ name, ownerAddress, }); - // dummy multicall to estimate gas - const multicallGasLimitPromise = estimateENSMulticallGasLimit({ - name, - records: { - coinAddress: null, - contentHash: null, - ensAssociatedAddress: ownerAddress, - text: [ - { key: 'key1', value: 'value1' }, - { key: 'key2', value: 'value2' }, - { - key: 'cover', - value: - 'https://cloudflare-ipfs.com/ipfs/QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/I/m/Vincent_van_Gogh_-_Self-Portrait_-_Google_Art_Project_(454045).jpg', - }, - ], - }, - }); + const gasLimits = await Promise.all([ commitGasLimitPromise, + setRecordsGasLimitPromise, setNameGasLimitPromise, - multicallGasLimitPromise, ]); - let [commitGasLimit, setNameGasLimit, multicallGasLimit] = gasLimits; + let [commitGasLimit, multicallGasLimit, setNameGasLimit] = gasLimits; commitGasLimit = commitGasLimit || `${ethUnits.ens_commit}`; - setNameGasLimit = setNameGasLimit || `${ethUnits.ens_set_name}`; multicallGasLimit = multicallGasLimit || `${ethUnits.ens_set_multicall}`; + setNameGasLimit = setNameGasLimit || `${ethUnits.ens_set_name}`; // we need to add register gas limit manually since the gas estimation will fail since the commit tx is not sent yet const registerWithConfigGasLimit = `${ethUnits.ens_register_with_config}`; @@ -294,14 +720,8 @@ export const estimateENSRegisterSetRecordsAndNameGasLimit = async ({ duration, rentPrice, salt, -}: { - name: string; - ownerAddress: string; - records: ENSRegistrationRecords; - duration: number; - rentPrice: string; - salt: string; -}) => { + setReverseRecord, +}: ENSActionParameters) => { const registerGasLimitPromise = estimateENSRegisterWithConfigGasLimit({ duration, name, @@ -309,11 +729,8 @@ export const estimateENSRegisterSetRecordsAndNameGasLimit = async ({ rentPrice, salt, }); - // WIP we need to set / unset these values from the UI - const setReverseRecord = true; - const setRecords = true; - const promises = [registerGasLimitPromise]; + if (setReverseRecord) { promises.push( estimateENSSetNameGasLimit({ @@ -322,9 +739,12 @@ export const estimateENSRegisterSetRecordsAndNameGasLimit = async ({ }) ); } - if (setRecords) { + + const ensRegistrationRecords = formatRecordsForTransaction(records); + const validRecords = recordsForTransactionAreValid(ensRegistrationRecords); + if (validRecords && records) { promises.push( - estimateENSMulticallGasLimit({ + estimateENSSetRecordsGasLimit({ name, records, }) @@ -332,8 +752,141 @@ export const estimateENSRegisterSetRecordsAndNameGasLimit = async ({ } const gasLimits = await Promise.all(promises); - const gasLimit = gasLimits.reduce((a, b) => add(a || 0, b || 0)); if (!gasLimit) return '0'; return gasLimit; }; + +export const estimateENSSetRecordsGasLimit = async ({ + name, + records, + ownerAddress, +}: + | { name: string; records: Records; ownerAddress?: string } + | ENSActionParameters) => { + let gasLimit: string | null = '0'; + const ensRegistrationRecords = formatRecordsForTransaction(records); + const validRecords = recordsForTransactionAreValid(ensRegistrationRecords); + if (validRecords) { + const shouldUseMulticall = shouldUseMulticallTransaction( + ensRegistrationRecords + ); + gasLimit = await (shouldUseMulticall + ? estimateENSMulticallGasLimit + : estimateENSSetTextGasLimit)({ + ...{ name, ownerAddress, records: ensRegistrationRecords }, + }); + } + return gasLimit; +}; + +export const formatRecordsForTransaction = ( + records?: Records +): ENSRegistrationRecords => { + const coinAddress = [] as { key: string; address: string }[]; + const text = [] as { key: string; value: string }[]; + let contentHash = null; + const ensAssociatedAddress = null; + records && + Object.entries(records).forEach(([key, value]) => { + switch (key) { + case ENS_RECORDS.cover: + case ENS_RECORDS.twitter: + case ENS_RECORDS.displayName: + case ENS_RECORDS.email: + case ENS_RECORDS.url: + case ENS_RECORDS.avatar: + case ENS_RECORDS.description: + case ENS_RECORDS.notice: + case ENS_RECORDS.keywords: + case ENS_RECORDS.discord: + case ENS_RECORDS.github: + case ENS_RECORDS.reddit: + case ENS_RECORDS.instagram: + case ENS_RECORDS.snapchat: + case ENS_RECORDS.telegram: + case ENS_RECORDS.ensDelegate: + if (value || value === '') { + text.push({ + key, + value: value, + }); + } + return; + case ENS_RECORDS.ETH: + case ENS_RECORDS.BTC: + case ENS_RECORDS.LTC: + case ENS_RECORDS.DOGE: + if (value || value === '') { + coinAddress.push({ address: value, key }); + } + return; + case ENS_RECORDS.content: + if (value || value === '') { + contentHash = value; + } + return; + } + }); + return { coinAddress, contentHash, ensAssociatedAddress, text }; +}; + +export const recordsForTransactionAreValid = ( + registrationRecords: ENSRegistrationRecords +) => { + const { + coinAddress, + contentHash, + ensAssociatedAddress, + text, + } = registrationRecords; + if ( + !coinAddress?.length && + !contentHash && + !ensAssociatedAddress && + !text?.length + ) { + return false; + } + return true; +}; + +export const shouldUseMulticallTransaction = ( + registrationRecords: ENSRegistrationRecords +) => { + const { + coinAddress, + contentHash, + ensAssociatedAddress, + text, + } = registrationRecords; + if ( + !coinAddress?.length && + !contentHash && + !ensAssociatedAddress && + text?.length === 1 + ) { + return false; + } + return true; +}; + +export const fetchReverseRecord = async (address: string) => { + try { + const provider = await getProviderForNetwork(); + const reverseRecord = await provider.lookupAddress(address); + return reverseRecord ?? ''; + } catch (e) { + return ''; + } +}; + +export const fetchResolver = async (ensName: string) => { + try { + const provider = await getProviderForNetwork(); + const resolver = await provider.getResolver(ensName); + return resolver ?? ({} as Resolver); + } catch (e) { + return {} as Resolver; + } +}; diff --git a/src/handlers/localstorage/accountLocal.ts b/src/handlers/localstorage/accountLocal.ts index 7a6a89732ac..453e48c5aa0 100644 --- a/src/handlers/localstorage/accountLocal.ts +++ b/src/handlers/localstorage/accountLocal.ts @@ -1,5 +1,6 @@ import { MMKV } from 'react-native-mmkv'; import { getAccountLocal, getKey, saveAccountLocal } from './common'; +import { ENSRegistrations } from '@rainbow-me/entities'; import { STORAGE_IDS } from '@rainbow-me/model/mmkv'; const accountAssetsDataVersion = '0.1.0'; @@ -25,6 +26,7 @@ const UNIQUE_TOKENS = 'uniquetokens'; const PINNED_COINS = 'pinnedCoins'; const HIDDEN_COINS = 'hiddenCoins'; const WEB_DATA_ENABLED = 'webDataEnabled'; +const ENS_REGISTRATIONS = 'ensRegistrations'; const storage = new MMKV({ id: STORAGE_IDS.ACCOUNT, @@ -35,6 +37,7 @@ export const accountLocalKeys = [ ACCOUNT_INFO, ASSET_PRICES_FROM_UNISWAP, ASSETS, + ENS_REGISTRATIONS, PURCHASE_TRANSACTIONS, SAVINGS, SHOWCASE_TOKENS, @@ -268,6 +271,37 @@ export const saveLocalTransactions = ( transactionsVersion ); +/** + * @desc get ENS registrations + * @param {String} [address] + * @param {String} [network] + * @return {Object} + */ +export const getLocalENSRegistrations = ( + accountAddress: any, + network: any +): Promise => + // @ts-expect-error ts-migrate(2345) FIXME: Argument of type '{}' is not assignable to paramet... Remove this comment to see the full error message + getAccountLocal(ENS_REGISTRATIONS, accountAddress, network, {}); + +/** + * @desc save ENS registrations + * @param {String} [address] + * @param {Array} [assets] + * @param {String} [network] + */ +export const saveLocalENSRegistrations = ( + ensRegistrations: ENSRegistrations, + accountAddress: any, + network: any +) => + saveAccountLocal( + ENS_REGISTRATIONS, + ensRegistrations, + accountAddress, + network + ); + /** * @desc get unique tokens * @param {String} [address] diff --git a/src/handlers/localstorage/ens.ts b/src/handlers/localstorage/ens.ts new file mode 100644 index 00000000000..81b7bf3ab9e --- /dev/null +++ b/src/handlers/localstorage/ens.ts @@ -0,0 +1,73 @@ +import { getGlobal, saveGlobal } from './common'; + +const ensProfileVersion = '0.1.0'; + +const ensProfileKey = (key: string) => `ensProfile.${key}`; +const ensProfileImagesKey = (key: string) => `ensProfileImages.${key}`; +const ensProfileRecordsKey = (key: string) => `ensProfileRecords.${key}`; +const ensResolveNameKey = (key: string) => `ensResolveName.${key}`; +const ensDomains = (key: string) => `ensDomains.${key}`; +const ensSeenOnchainDataDisclaimerKey = 'ensProfile.seenOnchainDisclaimer'; + +export const getProfile = async (key: string) => { + const profile = await getGlobal(ensProfileKey(key), null, ensProfileVersion); + return profile ? JSON.parse(profile) : null; +}; + +export const saveProfile = (key: string, value: Object) => + saveGlobal(ensProfileKey(key), JSON.stringify(value), ensProfileVersion); + +export const getProfileImages = async (key: string) => { + const images = await getGlobal( + ensProfileImagesKey(key), + null, + ensProfileVersion + ); + return images ? JSON.parse(images) : null; +}; + +export const saveProfileImages = (key: string, value: Object) => + saveGlobal( + ensProfileImagesKey(key), + JSON.stringify(value), + ensProfileVersion + ); + +export const getProfileRecords = async (key: string) => { + const records = await getGlobal( + ensProfileRecordsKey(key), + null, + ensProfileVersion + ); + return records ? JSON.parse(records) : null; +}; + +export const saveProfileRecords = (key: string, value: Object) => + saveGlobal( + ensProfileRecordsKey(key), + JSON.stringify(value), + ensProfileVersion + ); + +export const getResolveName = (key: string) => + getGlobal(ensResolveNameKey(key), null); + +export const saveResolveName = (key: string, value: string) => + saveGlobal(ensResolveNameKey(key), value); + +export const getSeenOnchainDataDisclaimer = () => + getGlobal(ensSeenOnchainDataDisclaimerKey, false); + +export const saveSeenOnchainDataDisclaimer = (value: boolean) => + saveGlobal(ensSeenOnchainDataDisclaimerKey, value); + +export const getENSDomains = (key: string) => getGlobal(ensDomains(key), []); + +export const setENSDomains = ( + key: string, + value: { + name: string; + owner: { id: string }; + images: { avatarUrl?: string | null; coverUrl?: string | null }; + }[] +) => saveGlobal(ensDomains(key), value); diff --git a/src/handlers/opensea-api.ts b/src/handlers/opensea-api.ts index 9cc04f488d5..b1a1e464e19 100644 --- a/src/handlers/opensea-api.ts +++ b/src/handlers/opensea-api.ts @@ -67,6 +67,75 @@ export const apiGetAccountUniqueTokens = async ( } }; +export const apiGetAccountUniqueToken = async ( + network: Network, + contractAddress: string, + tokenId: string, + // The `forceUpdate` param triggers an NFT metadata refresh + { forceUpdate = false }: { forceUpdate?: boolean } = {} +) => { + try { + const isPolygon = network === NetworkTypes.polygon; + const networkPrefix = network === NetworkTypes.mainnet ? '' : `${network}-`; + const url = `https://${networkPrefix}${NFT_API_URL}/api/v1/asset/${contractAddress}/${tokenId}${ + forceUpdate ? '?force_update=true' : '' + }`; + const urlV2 = `https://${NFT_API_URL}/api/v2/beta/asset/${contractAddress}/${tokenId}${ + forceUpdate ? '?force_update=true' : '' + }`; + const { data } = await rainbowFetch(isPolygon ? urlV2 : url, { + headers: { + 'Accept': 'application/json', + 'X-Api-Key': NFT_API_KEY, + }, + method: 'get', + timeout: 10000, // 10 secs + }); + return isPolygon + ? parseAccountUniqueTokensPolygon({ + data: { results: [data] }, + })?.[0] + : parseAccountUniqueTokens({ data: { assets: [data] } })?.[0]; + } catch (error) { + logger.sentry('Error getting unique token', error); + captureException(new Error('Opensea: Error getting unique token')); + throw error; + } +}; + +export const apiGetUniqueTokenImage = async ( + contractAddress: string, + tokenId: string +) => { + try { + const url = `https://${NFT_API_URL}/api/v1/asset/${contractAddress}/${tokenId}`; + const data = await rainbowFetch(url, { + headers: { + 'Accept': 'application/json', + 'X-Api-Key': NFT_API_KEY, + }, + method: 'get', + timeout: 10000, // 10 secs + }); + const { + image_url, + image_thumbnail_url, + image_original_url, + image_preview_url, + } = data?.data; + return { + image_original_url, + image_preview_url, + image_thumbnail_url, + image_url, + }; + } catch (error) { + logger.sentry('Error getting unique token image', error); + captureException(new Error('Opensea: Error getting unique token image')); + throw error; + } +}; + export const apiGetUniqueTokenFloorPrice = async ( network: any, urlSuffixForAsset: any diff --git a/src/handlers/pinata.ts b/src/handlers/pinata.ts new file mode 100644 index 00000000000..af259c67509 --- /dev/null +++ b/src/handlers/pinata.ts @@ -0,0 +1,53 @@ +import { + // @ts-expect-error ts-migrate(2305) FIXME: Module '"react-native-dotenv"' has no exported mem... Remove this comment to see the full error message + PINATA_API_KEY, + // @ts-expect-error ts-migrate(2305) FIXME: Module '"react-native-dotenv"' has no exported mem... Remove this comment to see the full error message + PINATA_API_SECRET, + // @ts-expect-error ts-migrate(2305) FIXME: Module '"react-native-dotenv"' has no exported mem... Remove this comment to see the full error message + PINATA_API_URL, + // @ts-expect-error ts-migrate(2305) FIXME: Module '"react-native-dotenv"' has no exported mem... Remove this comment to see the full error message + PINATA_GATEWAY_URL, +} from 'react-native-dotenv'; +import RNFS from 'react-native-fs'; + +type UploadFilesResponse = { + IpfsHash: string; + PinSize: number; + Timestamp: string; + isDuplicate: boolean; +}; +export type UploadImageReturnData = UploadFilesResponse & { + url: string; +}; + +export async function uploadImage({ + path, + filename, + mime, +}: { + filename: string; + path: string; + mime: string; +}): Promise { + const response = await RNFS.uploadFiles({ + files: [ + { + filename, + filepath: path, + filetype: mime, + name: 'file', + }, + ], + headers: { + pinata_api_key: PINATA_API_KEY, + pinata_secret_api_key: PINATA_API_SECRET, + }, + method: 'POST', + toUrl: `${PINATA_API_URL}/pinning/pinFileToIPFS`, + }).promise; + const parsedBody = JSON.parse(response.body) as UploadFilesResponse; + return { + ...parsedBody, + url: `${PINATA_GATEWAY_URL}/ipfs/${parsedBody.IpfsHash}`, + }; +} diff --git a/src/handlers/simplehash.ts b/src/handlers/simplehash.ts new file mode 100644 index 00000000000..245d3710775 --- /dev/null +++ b/src/handlers/simplehash.ts @@ -0,0 +1,41 @@ +import { captureException } from '@sentry/react-native'; +// @ts-expect-error +import { SIMPLEHASH_API_KEY } from 'react-native-dotenv'; +import { RainbowFetchClient } from '../rainbow-fetch'; +import { logger } from '@rainbow-me/utils'; + +const chains = { + ethereum: 'ethereum', +} as const; +type Chain = keyof typeof chains; + +const simplehashApi = new RainbowFetchClient({ + baseURL: 'https://api.simplehash.com/api', +}); + +export async function getNFTByTokenId({ + chain = chains.ethereum, + contractAddress, + tokenId, +}: { + chain?: Chain; + contractAddress: string; + tokenId: string; +}) { + try { + const response = await simplehashApi.get( + `/v0/nfts/${chain}/${contractAddress}/${tokenId}`, + { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'x-api-key': SIMPLEHASH_API_KEY, + }, + } + ); + return response.data; + } catch (error) { + logger.sentry(`Error fetching simplehash NFT: ${error}`); + captureException(error); + } +} diff --git a/src/handlers/svgs.ts b/src/handlers/svgs.ts index fef27fbcb5d..329dcfa1c68 100644 --- a/src/handlers/svgs.ts +++ b/src/handlers/svgs.ts @@ -11,8 +11,8 @@ import { // @ts-expect-error ts-migrate(2305) FIXME: Module '"react-native-dotenv"' has no exported mem... Remove this comment to see the full error message CLOUDINARY_CLOUD_NAME as cloudName, } from 'react-native-dotenv'; -import isSupportedUriExtension from '@rainbow-me/helpers/isSupportedUriExtension'; import { deviceUtils } from '@rainbow-me/utils'; +import isSVGImage from '@rainbow-me/utils/isSVG'; cloudinaryConfig({ api_key: apiKey, @@ -45,6 +45,6 @@ function svgToPng(url: any, big = false) { } export default function svgToPngIfNeeded(url: any, big: any) { - const isSVG = isSupportedUriExtension(url, ['.svg']); + const isSVG = isSVGImage(url); return isSVG ? svgToPng(url, big) : url; } diff --git a/src/handlers/web3.ts b/src/handlers/web3.ts index d90beaff069..9c2e7f36fac 100644 --- a/src/handlers/web3.ts +++ b/src/handlers/web3.ts @@ -320,7 +320,6 @@ export async function estimateGasWithPadding( ): Promise { try { const p = provider || web3Provider; - if (!p) { return null; } diff --git a/src/helpers/accountInfo.js b/src/helpers/accountInfo.js index 85f81fb0f9f..47c20d9eab9 100644 --- a/src/helpers/accountInfo.js +++ b/src/helpers/accountInfo.js @@ -4,7 +4,7 @@ import { returnStringFirstEmoji, } from '../helpers/emojiHandler'; import { address } from '../utils/abbreviations'; -import { addressHashedEmoji } from '../utils/profileUtils'; +import { addressHashedEmoji, isValidImagePath } from '../utils/profileUtils'; export function getAccountProfileInfo( selectedWallet, @@ -46,7 +46,7 @@ export function getAccountProfileInfo( emojiAvatar || addressHashedEmoji(accountAddress) ); const accountColor = color; - const accountImage = image; + const accountImage = isValidImagePath(image) ? image : null; return { accountAddress, diff --git a/src/helpers/assets.js b/src/helpers/assets.js index 43566124896..a710d0cf5ec 100644 --- a/src/helpers/assets.js +++ b/src/helpers/assets.js @@ -329,10 +329,11 @@ const regex = RegExp(/\s*(the)\s/, 'i'); export const buildBriefUniqueTokenList = ( uniqueTokens, - selectedShowcaseTokens + selectedShowcaseTokens, + sellingTokens = [] ) => { const uniqueTokensInShowcase = uniqueTokens - .filter(({ uniqueId }) => selectedShowcaseTokens.includes(uniqueId)) + .filter(({ uniqueId }) => selectedShowcaseTokens?.includes(uniqueId)) .map(({ uniqueId }) => uniqueId); const grouped2 = groupBy(uniqueTokens, token => token.familyName); const families2 = sortBy(Object.keys(grouped2), row => @@ -344,7 +345,12 @@ export const buildBriefUniqueTokenList = ( { type: 'NFTS_HEADER_SPACE_AFTER', uid: 'nfts-header-space-after' }, ]; if (uniqueTokensInShowcase.length > 0) { - result.push({ name: 'Showcase', type: 'FAMILY_HEADER', uid: 'showcase' }); + result.push({ + name: 'Showcase', + total: uniqueTokensInShowcase.length, + type: 'FAMILY_HEADER', + uid: 'showcase', + }); for (let index = 0; index < uniqueTokensInShowcase.length; index++) { const uniqueId = uniqueTokensInShowcase[index]; result.push({ @@ -357,6 +363,24 @@ export const buildBriefUniqueTokenList = ( result.push({ type: 'NFT_SPACE_AFTER', uid: `showcase-space-after` }); } + if (sellingTokens.length > 0) { + result.push({ + name: 'Selling', + total: sellingTokens.length, + type: 'FAMILY_HEADER', + uid: 'selling', + }); + for (let index = 0; index < sellingTokens.length; index++) { + const uniqueId = sellingTokens[index].uniqueId; + result.push({ + index, + type: 'NFT', + uid: uniqueId, + uniqueId, + }); + } + result.push({ type: 'NFT_SPACE_AFTER', uid: `showcase-space-after` }); + } for (let family of families2) { result.push({ image: grouped2[family][0].familyImage, @@ -378,4 +402,4 @@ export const buildBriefUniqueTokenList = ( }; export const buildUniqueTokenName = ({ collection, id, name }) => - name || `${collection.name} #${id}`; + name || `${collection?.name} #${id}`; diff --git a/src/helpers/buildWalletSections.js b/src/helpers/buildWalletSections.js index bdd393365d2..474ecc0ec30 100644 --- a/src/helpers/buildWalletSections.js +++ b/src/helpers/buildWalletSections.js @@ -380,6 +380,7 @@ const withBriefBalanceSection = ( return [ { type: 'ASSETS_HEADER', + uid: 'assets-header', value: totalValue, }, { diff --git a/src/helpers/ens.ts b/src/helpers/ens.ts index 977d44e1e22..eb15c1d06d9 100644 --- a/src/helpers/ens.ts +++ b/src/helpers/ens.ts @@ -2,7 +2,7 @@ import { formatsByName } from '@ensdomains/address-encoder'; import { hash } from '@ensdomains/eth-ens-namehash'; import { Wallet } from '@ethersproject/wallet'; import { BigNumberish, Contract } from 'ethers'; -import { keccak256, toUtf8Bytes } from 'ethers/lib/utils'; +import lang from 'i18n-js'; import { atom } from 'recoil'; import { InlineFieldProps } from '../components/inputs/InlineField'; import { @@ -11,9 +11,11 @@ import { convertAmountAndPriceToNativeDisplay, divide, fromWei, + handleSignificantDecimals, multiply, } from './utilities'; -import { toHex, web3Provider } from '@rainbow-me/handlers/web3'; +import { ENSRegistrationRecords, EthereumAddress } from '@rainbow-me/entities'; +import { getProviderForNetwork, toHex } from '@rainbow-me/handlers/web3'; import { gweiToWei } from '@rainbow-me/parsers'; import { ENSBaseRegistrarImplementationABI, @@ -28,38 +30,34 @@ import { ensReverseRegistrarAddress, } from '@rainbow-me/references'; import { colors } from '@rainbow-me/styles'; +import { labelhash } from '@rainbow-me/utils'; +import { + encodeContenthash, + isValidContenthash, +} from '@rainbow-me/utils/contenthash'; + +export const ENS_SECONDS_WAIT = 60; export enum ENSRegistrationTransactionType { COMMIT = 'commit', REGISTER_WITH_CONFIG = 'registerWithConfig', + RENEW = 'renew', SET_TEXT = 'setText', SET_NAME = 'setName', MULTICALL = 'multicall', } -export interface ENSRegistrationRecords { - coinAddress: { key: string; address: string }[] | null; - contentHash: string | null; - ensAssociatedAddress: string | null; - text: { key: string; value: string }[] | null; -} - -const getENSRegistryContract = () => { - return new Contract( - ensRegistryAddress, - ENSRegistryWithFallbackABI, - web3Provider - ); -}; -enum ENS_RECORDS { +export enum ENS_RECORDS { ETH = 'ETH', BTC = 'BTC', LTC = 'LTC', DOGE = 'DOGE', displayName = 'me.rainbow.displayName', + cover = 'cover', content = 'content', - email = 'email', url = 'url', + email = 'email', + website = 'website', avatar = 'avatar', description = 'description', notice = 'notice', @@ -70,28 +68,58 @@ enum ENS_RECORDS { instagram = 'com.instagram', snapchat = 'com.snapchat', twitter = 'com.twitter', - telegram = 'com.telegram', + telegram = 'org.telegram', ensDelegate = 'eth.ens.delegate', + pronouns = 'pronouns', +} + +export enum REGISTRATION_STEPS { + COMMIT = 'COMMIT', + EDIT = 'EDIT', + REGISTER = 'REGISTER', + RENEW = 'RENEW', + SET_NAME = 'SET_NAME', + WAIT_COMMIT_CONFIRMATION = 'WAIT_COMMIT_CONFIRMATION', + WAIT_ENS_COMMITMENT = 'WAIT_ENS_COMMITMENT', +} + +export enum REGISTRATION_MODES { + CREATE = 'CREATE', + EDIT = 'EDIT', + RENEW = 'RENEW', + SET_NAME = 'SET_NAME', } export type TextRecordField = { id: string; - key: string; + key: ENS_RECORDS; label: InlineFieldProps['label']; placeholder: InlineFieldProps['placeholder']; inputProps?: InlineFieldProps['inputProps']; - validations?: InlineFieldProps['validations']; + validations?: InlineFieldProps['validations'] & { + onSubmit?: { + match?: { + value: RegExp; + message: string; + }; + validate?: { + callback: (value: string) => boolean; + message: string; + }; + }; + }; + startsWith?: string; }; -const textRecordFields = { +export const textRecordFields = { [ENS_RECORDS.displayName]: { id: 'name', inputProps: { maxLength: 50, }, key: ENS_RECORDS.displayName, - label: 'Name', - placeholder: 'Add a display name', + label: lang.t('profiles.create.name'), + placeholder: lang.t('profiles.create.name_placeholder'), }, [ENS_RECORDS.description]: { id: 'bio', @@ -100,8 +128,26 @@ const textRecordFields = { multiline: true, }, key: ENS_RECORDS.description, - label: 'Bio', - placeholder: 'Add a bio to your profile', + label: lang.t('profiles.create.bio'), + placeholder: lang.t('profiles.create.bio_placeholder'), + }, + [ENS_RECORDS.url]: { + id: 'website', + inputProps: { + keyboardType: 'url', + maxLength: 100, + }, + key: ENS_RECORDS.url, + label: lang.t('profiles.create.website'), + placeholder: lang.t('profiles.create.website_placeholder'), + validations: { + onSubmit: { + match: { + message: lang.t('profiles.create.website_submit_message'), + value: /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/, + }, + }, + }, }, [ENS_RECORDS.twitter]: { id: 'twitter', @@ -109,23 +155,56 @@ const textRecordFields = { maxLength: 16, }, key: ENS_RECORDS.twitter, - label: 'Twitter', - placeholder: '@username', + label: lang.t('profiles.create.twitter'), + placeholder: lang.t('profiles.create.username_placeholder'), + startsWith: '@', validations: { - allowCharacterRegex: { - match: /^@?\w*$/, + onChange: { + match: /^\w*$/, }, }, }, - [ENS_RECORDS.url]: { - id: 'website', + [ENS_RECORDS.email]: { + id: 'email', inputProps: { - keyboardType: 'url', - maxLength: 100, + maxLength: 50, + }, + key: ENS_RECORDS.email, + label: lang.t('profiles.create.email'), + placeholder: lang.t('profiles.create.email_placeholder'), + validations: { + onSubmit: { + match: { + message: lang.t('profiles.create.email_submit_message'), + value: /^\S+@\S+\.\S+$/, + }, + }, }, - key: ENS_RECORDS.url, - label: 'Website', - placeholder: 'Add your website', + }, + [ENS_RECORDS.instagram]: { + id: 'instagram', + inputProps: { + maxLength: 30, + }, + key: ENS_RECORDS.instagram, + label: lang.t('profiles.create.instagram'), + placeholder: lang.t('profiles.create.username_placeholder'), + startsWith: '@', + validations: { + onChange: { + match: /^([\w.])*$/, + }, + }, + }, + [ENS_RECORDS.discord]: { + id: 'discord', + inputProps: { + maxLength: 50, + }, + key: ENS_RECORDS.discord, + label: lang.t('profiles.create.discord'), + placeholder: lang.t('profiles.create.username_placeholder'), + startsWith: '@', }, [ENS_RECORDS.github]: { id: 'github', @@ -133,20 +212,28 @@ const textRecordFields = { maxLength: 20, }, key: ENS_RECORDS.github, - label: 'GitHub', - placeholder: '@username', + label: lang.t('profiles.create.github'), + placeholder: lang.t('profiles.create.username_placeholder'), }, - [ENS_RECORDS.instagram]: { - id: 'instagram', + [ENS_RECORDS.BTC]: { + id: 'btc', inputProps: { - maxLength: 30, + maxLength: 42, + multiline: true, }, - key: ENS_RECORDS.instagram, - label: 'Instagram', - placeholder: '@username', + key: ENS_RECORDS.BTC, + label: lang.t('profiles.create.btc'), + placeholder: lang.t('profiles.create.wallet_placeholder', { + coin: lang.t('profiles.create.btc'), + }), validations: { - allowCharacterRegex: { - match: /^@?([\w.])*$/, + onSubmit: { + validate: { + callback: value => validateCoinRecordValue(value, ENS_RECORDS.BTC), + message: lang.t('profiles.create.invalid_asset', { + coin: ENS_RECORDS.BTC, + }), + }, }, }, }, @@ -156,74 +243,191 @@ const textRecordFields = { maxLength: 16, }, key: ENS_RECORDS.snapchat, - label: 'Snapchat', - placeholder: '@username', + label: lang.t('profiles.create.snapchat'), + placeholder: lang.t('profiles.create.username_placeholder'), + startsWith: '@', validations: { - allowCharacterRegex: { - match: /^@?([\w.])*$/, + onChange: { + match: /^([\w.])*$/, }, }, }, - [ENS_RECORDS.discord]: { - id: 'discord', + [ENS_RECORDS.telegram]: { + id: 'telegram', inputProps: { - maxLength: 50, + maxLength: 30, + }, + key: ENS_RECORDS.telegram, + label: lang.t('profiles.create.telegram'), + placeholder: lang.t('profiles.create.username_placeholder'), + startsWith: '@', + }, + [ENS_RECORDS.reddit]: { + id: 'reddit', + inputProps: { + maxLength: 30, + }, + key: ENS_RECORDS.reddit, + label: lang.t('profiles.create.reddit'), + placeholder: lang.t('profiles.create.username_placeholder'), + startsWith: '@', + }, + [ENS_RECORDS.pronouns]: { + id: 'pronouns', + inputProps: { + maxLength: 42, + }, + key: ENS_RECORDS.pronouns, + label: lang.t('profiles.create.pronouns'), + placeholder: lang.t('profiles.create.pronouns_placeholder'), + }, + [ENS_RECORDS.notice]: { + id: 'notice', + inputProps: { + maxLength: 100, + }, + key: ENS_RECORDS.notice, + label: lang.t('profiles.create.notice'), + placeholder: lang.t('profiles.create.notice_placeholder'), + }, + [ENS_RECORDS.keywords]: { + id: 'keywords', + inputProps: { + maxLength: 100, + }, + key: ENS_RECORDS.keywords, + label: lang.t('profiles.create.keywords'), + placeholder: lang.t('profiles.create.keywords_placeholder'), + }, + [ENS_RECORDS.LTC]: { + id: 'ltc', + inputProps: { + maxLength: 64, + }, + key: ENS_RECORDS.LTC, + label: lang.t('profiles.create.ltc'), + placeholder: lang.t('profiles.create.wallet_placeholder', { + coin: lang.t('profiles.create.ltc'), + }), + validations: { + onSubmit: { + validate: { + callback: value => validateCoinRecordValue(value, ENS_RECORDS.LTC), + message: lang.t('profiles.create.invalid_asset', { + coin: ENS_RECORDS.LTC, + }), + }, + }, }, - key: ENS_RECORDS.discord, - label: 'Discord', - placeholder: '@username', }, -} as const; + [ENS_RECORDS.DOGE]: { + id: 'doge', + inputProps: { + maxLength: 34, + }, + key: ENS_RECORDS.DOGE, + label: lang.t('profiles.create.doge'), + placeholder: lang.t('profiles.create.wallet_placeholder', { + coin: lang.t('profiles.create.doge'), + }), + validations: { + onSubmit: { + validate: { + callback: value => validateCoinRecordValue(value, ENS_RECORDS.DOGE), + message: lang.t('profiles.create.invalid_asset', { + coin: ENS_RECORDS.DOGE, + }), + }, + }, + }, + }, + [ENS_RECORDS.content]: { + id: 'content', + inputProps: {}, + key: ENS_RECORDS.content, + label: lang.t('profiles.create.content'), + placeholder: lang.t('profiles.create.content_placeholder'), + validations: { + onSubmit: { + validate: { + callback: value => validateContentHashRecordValue(value), + message: lang.t('profiles.create.invalid_content_hash'), + }, + }, + }, + }, +} as { + [key in ENS_RECORDS]?: TextRecordField; +}; export const ENS_DOMAIN = '.eth'; -const getENSRegistrarControllerContract = ( +const getENSRegistrarControllerContract = async ( wallet?: Wallet, registrarAddress?: string ) => { + const signerOrProvider = wallet || (await getProviderForNetwork()); return new Contract( registrarAddress || ensETHRegistrarControllerAddress, ENSETHRegistrarControllerABI, - wallet || web3Provider + signerOrProvider ); }; -const getENSPublicResolverContract = (wallet?: Wallet) => { + +const getENSPublicResolverContract = async ( + wallet?: Wallet, + resolverAddress?: EthereumAddress +) => { + const signerOrProvider = wallet || (await getProviderForNetwork()); return new Contract( - ensPublicResolverAddress, + resolverAddress || ensPublicResolverAddress, ENSPublicResolverABI, - wallet || web3Provider + signerOrProvider ); }; -const getENSReverseRegistrarContract = (wallet?: Wallet) => { +const getENSReverseRegistrarContract = async (wallet?: Wallet) => { + const signerOrProvider = wallet || (await getProviderForNetwork()); return new Contract( ensReverseRegistrarAddress, ENSReverseRegistrarABI, - wallet || web3Provider + signerOrProvider ); }; -const getENSBaseRegistrarImplementationContract = (wallet?: Wallet) => { +const getENSBaseRegistrarImplementationContract = async (wallet?: Wallet) => { + const signerOrProvider = wallet || (await getProviderForNetwork()); return new Contract( ensBaseRegistrarImplementationAddress, ENSBaseRegistrarImplementationABI, - wallet || web3Provider + signerOrProvider ); }; -const getResolver = async (name: string): Promise => - getENSRegistryContract().resolver(name); +const getENSRegistryContract = async () => { + const provider = await getProviderForNetwork(); + return new Contract(ensRegistryAddress, ENSRegistryWithFallbackABI, provider); +}; -const getAvailable = async (name: string): Promise => - getENSRegistrarControllerContract().available(name); +const getAvailable = async (name: string): Promise => { + const contract = await getENSRegistrarControllerContract(); + return contract.available(name); +}; -const getNameExpires = async (name: string): Promise => - getENSBaseRegistrarImplementationContract().nameExpires( - keccak256(toUtf8Bytes(name)) - ); +const getNameExpires = async (name: string): Promise => { + const contract = await getENSBaseRegistrarImplementationContract(); + return contract.nameExpires(labelhash(name)); +}; -const getRentPrice = async (name: string, duration: number): Promise => - getENSRegistrarControllerContract().rentPrice(name, duration); +const getNameOwner = async (name: string): Promise => { + const contract = await getENSRegistryContract(); + return contract.owner(hash(name)); +}; + +const getRentPrice = async (name: string, duration: number): Promise => { + const contract = await getENSRegistrarControllerContract(); + return contract.rentPrice(name, duration); +}; const setupMulticallRecords = ( name: string, @@ -234,9 +438,9 @@ const setupMulticallRecords = ( const namehash = hash(name); const data = []; - // ens associated address const ensAssociatedRecord = records.ensAssociatedAddress; + if ( Boolean(ensAssociatedRecord) && typeof ensAssociatedRecord === 'string' && @@ -287,20 +491,22 @@ const setupMulticallRecords = ( const textAssociatedRecord = records.text; if (textAssociatedRecord) { data.push( - textAssociatedRecord.map(textRecord => { - return resolver.encodeFunctionData('setText', [ - namehash, - textRecord.key, - textRecord.value, - ]); - }) + textAssociatedRecord + .filter(textRecord => Boolean(textRecord.value)) + .map(textRecord => { + return resolver.encodeFunctionData('setText', [ + namehash, + textRecord.key, + textRecord.value, + ]); + }) ); } // flatten textrecords and addresses and remove undefined return data.flat().filter(Boolean); }; -export const generateSalt = () => { +const generateSalt = () => { const random = new Uint8Array(32); crypto.getRandomValues(random); const salt = @@ -320,6 +526,7 @@ const getENSExecutionDetails = async ({ duration, records, wallet, + resolverAddress, }: { name?: string; type: ENSRegistrationTransactionType; @@ -329,6 +536,7 @@ const getENSExecutionDetails = async ({ records?: ENSRegistrationRecords; wallet?: Wallet; salt?: string; + resolverAddress?: EthereumAddress; }): Promise<{ methodArguments: any[] | null; value: BigNumberish | null; @@ -341,7 +549,9 @@ const getENSExecutionDetails = async ({ switch (type) { case ENSRegistrationTransactionType.COMMIT: { if (!name || !ownerAddress) throw new Error('Bad arguments for commit'); - const registrarController = getENSRegistrarControllerContract(wallet); + const registrarController = await getENSRegistrarControllerContract( + wallet + ); const commitment = await registrarController.makeCommitmentWithConfig( name.replace(ENS_DOMAIN, ''), ownerAddress, @@ -350,7 +560,14 @@ const getENSExecutionDetails = async ({ ownerAddress ); args = [commitment]; - contract = getENSRegistrarControllerContract(wallet); + contract = registrarController; + break; + } + case ENSRegistrationTransactionType.MULTICALL: { + if (!name || !records) throw new Error('Bad arguments for multicall'); + contract = await getENSPublicResolverContract(wallet, resolverAddress); + const data = setupMulticallRecords(name, records, contract) || []; + args = [data]; break; } case ENSRegistrationTransactionType.REGISTER_WITH_CONFIG: { @@ -365,30 +582,31 @@ const getENSExecutionDetails = async ({ ensPublicResolverAddress, ownerAddress, ]; - contract = getENSRegistrarControllerContract(wallet); + contract = await getENSRegistrarControllerContract(wallet); + break; + } + case ENSRegistrationTransactionType.RENEW: { + if (!name || !duration || !rentPrice) + throw new Error('Bad arguments for renew'); + value = toHex(addBuffer(rentPrice, 1.1)); + args = [name.replace(ENS_DOMAIN, ''), duration]; + contract = await getENSRegistrarControllerContract(wallet); break; } + case ENSRegistrationTransactionType.SET_NAME: + if (!name) throw new Error('Bad arguments for setName'); + args = [name]; + contract = await getENSReverseRegistrarContract(wallet); + break; case ENSRegistrationTransactionType.SET_TEXT: { if (!name || !records || !records?.text?.[0]) throw new Error('Bad arguments for setText'); const record = records?.text[0]; const namehash = hash(name); args = [namehash, record.key, record.value]; - contract = getENSPublicResolverContract(); + contract = await getENSPublicResolverContract(wallet, resolverAddress); break; } - case ENSRegistrationTransactionType.MULTICALL: { - if (!name || !records) throw new Error('Bad arguments for multicall'); - contract = getENSPublicResolverContract(wallet); - const data = setupMulticallRecords(name, records, contract) || []; - args = [data]; - break; - } - case ENSRegistrationTransactionType.SET_NAME: - if (!name) throw new Error('Bad arguments for setName'); - args = [name]; - contract = getENSReverseRegistrarContract(wallet); - break; } return { contract, @@ -433,6 +651,7 @@ const formatTotalRegistrationCost = ( skipDecimals: boolean = false ) => { const networkFeeInEth = fromWei(wei); + const eth = handleSignificantDecimals(networkFeeInEth, 3); const { amount, display } = convertAmountAndPriceToNativeDisplay( networkFeeInEth, @@ -445,10 +664,29 @@ const formatTotalRegistrationCost = ( return { amount, display, + eth, wei, }; }; +const validateCoinRecordValue = (value: string, coin: string) => { + try { + formatsByName[coin].decoder(value); + return true; + } catch (e) { + return false; + } +}; + +const validateContentHashRecordValue = (value: string) => { + const { encoded, error: encodeError } = encodeContenthash(value); + if (!encodeError && encoded) { + return isValidContenthash(encoded); + } else { + return false; + } +}; + const getRentPricePerYear = (rentPrice: string, duration: number) => divide(rentPrice, duration); @@ -495,12 +733,12 @@ const formatRentPrice = ( }; const accentColorAtom = atom({ - default: colors.appleBlue, + default: colors.purple, key: 'ens.accentColor', }); export { - ENS_RECORDS, + generateSalt, getENSRecordKeys, getENSRecordValues, getENSRegistryContract, @@ -508,11 +746,10 @@ export { getENSBaseRegistrarImplementationContract, getENSPublicResolverContract, getENSReverseRegistrarContract, - getResolver, getAvailable, getNameExpires, + getNameOwner, getRentPrice, - textRecordFields, getENSExecutionDetails, formatEstimatedNetworkFee, formatTotalRegistrationCost, diff --git a/src/helpers/utilities.ts b/src/helpers/utilities.ts index 802b520587e..e58a4380c34 100644 --- a/src/helpers/utilities.ts +++ b/src/helpers/utilities.ts @@ -153,9 +153,9 @@ export const add = (numberOne: BigNumberish, numberTwo: BigNumberish): string => new BigNumber(numberOne).plus(numberTwo).toFixed(); export const addDisplay = (numberOne: string, numberTwo: string): string => { - const template = numberOne.split(/\d+\.\d+/); + const template = numberOne.split(/^(\D*)(.*)/); const display = currency(numberOne, { symbol: '' }).add(numberTwo).format(); - return template.map(item => (item === '' ? `${display}` : item)).join(''); + return [template[1], display].join(''); }; export const multiply = ( diff --git a/src/helpers/walletLoadingStates.ts b/src/helpers/walletLoadingStates.ts index ca6370d2677..a4de08d372f 100644 --- a/src/helpers/walletLoadingStates.ts +++ b/src/helpers/walletLoadingStates.ts @@ -3,5 +3,6 @@ export default { CREATING_WALLET: 'Creating wallet...', FETCHING_PASSWORD: 'Fetching Password...', IMPORTING_WALLET: 'Importing...', + IMPORTING_WALLET_SILENTLY: '', RESTORING_WALLET: 'Restoring...', }; diff --git a/src/hooks/index.js b/src/hooks/index.js index 7bf54966ec8..9ceaf89bb63 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -8,6 +8,10 @@ export { default as useAccountAsset } from './useAccountAsset'; export { default as useSortedAccountAssets } from './useSortedAccountAssets'; export { default as useFrameDelayedValue } from './useFrameDelayedValue'; export { default as useAccountEmptyState } from './useAccountEmptyState'; +export { + default as useAccountENSDomains, + prefetchAccountENSDomains, +} from './useAccountENSDomains'; export { default as useAccountProfile } from './useAccountProfile'; export { default as useAccountSettings } from './useAccountSettings'; export { default as useAccountTransactions } from './useAccountTransactions'; @@ -29,11 +33,29 @@ export { default as useCollectible } from './useCollectible'; export { default as useColorForAsset } from './useColorForAsset'; export { default as useContacts } from './useContacts'; export { default as useDimensions, DeviceDimensions } from './useDimensions'; +export { default as useDeleteWallet } from './useDeleteWallet'; export { default as useDPI } from './useDPI'; export { default as useEffectDebugger } from './useEffectDebugger'; export { default as useEmailRainbow } from './useEmailRainbow'; -export { default as useENSProfileForm } from './useENSProfileForm'; +export { default as useENSPendingRegistrations } from './useENSPendingRegistrations'; +export { default as useENSProfile } from './useENSProfile'; +export { default as useENSProfileImages } from './useENSProfileImages'; +export { default as useFadeImage } from './useFadeImage'; +export { default as useTrackENSProfile } from './useTrackENSProfile'; +export { default as useENSRecordDisplayProperties } from './useENSRecordDisplayProperties'; +export { default as useENSRegistration } from './useENSRegistration'; +export { default as useENSModifiedRegistration } from './useENSModifiedRegistration'; +export { default as useENSRegistrationActionHandler } from './useENSRegistrationActionHandler'; +export { default as useENSRegistrationStepHandler } from './useENSRegistrationStepHandler'; +export { default as useENSRegistrationCosts } from './useENSRegistrationCosts'; +export { default as useENSRegistrationForm } from './useENSRegistrationForm'; +export { default as useENSResolveName } from './useENSResolveName'; +export { default as useENSProfileRecords } from './useENSProfileRecords'; +export { default as useENSSearch } from './useENSSearch'; export { default as useExpandedStateNavigation } from './useExpandedStateNavigation'; +export { default as useExternalWalletSectionsData } from './useExternalWalletSectionsData'; +export { default as useFetchUniqueTokens } from './useFetchUniqueTokens'; +export { default as useFirstTransactionTimestamp } from './useFirstTransactionTimestamp'; export { default as useGas } from './useGas'; export { default as useGenericAsset } from './useGenericAsset'; export { default as useHeight } from './useHeight'; @@ -50,6 +72,7 @@ export { default as useIsMounted } from './useIsMounted'; export { default as useIsWalletEthZero } from './useIsWalletEthZero'; export { default as useKeyboardHeight } from './useKeyboardHeight'; export { default as useLoadAccountData } from './useLoadAccountData'; +export { default as useLoadAccountLateData } from './useLoadAccountLateData'; export { default as useLoadGlobalEarlyData } from './useLoadGlobalEarlyData'; export { default as useLoadGlobalLateData } from './useLoadGlobalLateData'; export { default as useLongPressEvents } from './useLongPressEvents'; @@ -72,6 +95,7 @@ export { default as useRouteExistsInNavigationState } from './useRouteExistsInNa export { default as useSafeImageUri } from './useSafeImageUri'; export { default as useSavingsAccount } from './useSavingsAccount'; export { default as useScanner } from './useScanner'; +export { default as useSelectImageMenu } from './useSelectImageMenu'; export { default as useSendSheetInputRefs } from './useSendSheetInputRefs'; export { default as useSendableUniqueTokens } from './useSendableUniqueTokens'; export { default as useSendFeedback } from './useSendFeedback'; @@ -91,6 +115,7 @@ export { default as useSwapDerivedOutputs } from './useSwapDerivedOutputs'; export { default as useTimeout } from './useTimeout'; export { default as useTopMovers } from './useTopMovers'; export { default as useTransactionConfirmation } from './useTransactionConfirmation'; +export { default as usePendingTransactions } from './usePendingTransactions'; export { default as useTransformOrigin } from './useTransformOrigin'; export { default as useUniswapAssetsInWallet } from './useUniswapAssetsInWallet'; export { default as useUniswapCalls } from './useUniswapCalls'; @@ -105,6 +130,7 @@ export { default as useWalletManualBackup } from './useWalletManualBackup'; export { default as useWallets } from './useWallets'; export { default as useWalletSectionsData } from './useWalletSectionsData'; export { default as useWalletsWithBalancesAndNames } from './useWalletsWithBalancesAndNames'; +export { default as useWatchWallet } from './useWatchWallet'; export { default as useWebData } from './useWebData'; export { default as useWyreApplePay } from './useWyreApplePay'; export { default as useForceUpdate } from './useForceUpdate'; @@ -123,6 +149,5 @@ export { useHardwareBackOnFocus, } from './useHardwareBack'; export { default as useUniswapCurrencyList } from './useUniswapCurrencyList'; -export { default as useENSRegistration } from './useENSRegistration'; -export { default as useENSRegistrationCosts } from './useENSRegistrationCosts'; -export { default as useENSProfile } from './useENSProfile'; +export { default as useWalletENSAvatar } from './useWalletENSAvatar'; +export { default as useImagePicker } from './useImagePicker'; diff --git a/src/hooks/useAccountENSDomains.ts b/src/hooks/useAccountENSDomains.ts new file mode 100644 index 00000000000..cdd8af44661 --- /dev/null +++ b/src/hooks/useAccountENSDomains.ts @@ -0,0 +1,87 @@ +import { useQuery } from 'react-query'; +import useAccountSettings from './useAccountSettings'; +import { EnsAccountRegistratonsData } from '@rainbow-me/apollo/queries'; +import { + fetchAccountRegistrations, + fetchImages, +} from '@rainbow-me/handlers/ens'; +import { + getENSDomains, + setENSDomains, +} from '@rainbow-me/handlers/localstorage/ens'; +import { queryClient } from '@rainbow-me/react-query/queryClient'; + +const queryKey = ({ accountAddress }: { accountAddress: string }) => [ + 'domains', + accountAddress, +]; + +const imagesQueryKey = ({ name }: { name: string }) => ['domainImages', name]; + +const STALE_TIME = 10000; + +async function fetchAccountENSDomains({ + accountAddress, +}: { + accountAddress: string; +}) { + if (!accountAddress) return []; + const result = await fetchAccountRegistrations(accountAddress); + const registrations = result.data?.account?.registrations || []; + const domains = await Promise.all( + registrations.map(async ({ domain }) => { + const images = await fetchAccountENSImages(domain.name); + return { + ...domain, + images, + }; + }) + ); + + return domains; +} + +async function fetchENSDomainsWithCache({ + accountAddress, +}: { + accountAddress: string; +}) { + const cachedDomains = await getENSDomains(accountAddress); + if (cachedDomains) + queryClient.setQueryData(queryKey({ accountAddress }), cachedDomains); + const ensDomains = await fetchAccountENSDomains({ accountAddress }); + setENSDomains(accountAddress, ensDomains); + return ensDomains; +} + +export async function prefetchAccountENSDomains({ + accountAddress, +}: { + accountAddress: string; +}) { + queryClient.prefetchQuery( + queryKey({ accountAddress }), + async () => fetchENSDomainsWithCache({ accountAddress }), + { staleTime: STALE_TIME } + ); +} + +async function fetchAccountENSImages(name: string) { + return queryClient.fetchQuery( + imagesQueryKey({ name }), + async () => await fetchImages(name), + { + staleTime: 120000, + } + ); +} + +export default function useAccountENSDomains() { + const { accountAddress } = useAccountSettings(); + + return useQuery< + EnsAccountRegistratonsData['account']['registrations'][number]['domain'][] + >(queryKey({ accountAddress }), async () => + fetchENSDomainsWithCache({ accountAddress }) + ); +} diff --git a/src/hooks/useAccountProfile.js b/src/hooks/useAccountProfile.js index a35b86a7405..5395e576860 100644 --- a/src/hooks/useAccountProfile.js +++ b/src/hooks/useAccountProfile.js @@ -6,13 +6,35 @@ import { getAccountProfileInfo } from '@rainbow-me/helpers/accountInfo'; export default function useAccountProfile() { const wallets = useWallets(); const { selectedWallet, walletNames } = wallets; - const { accountAddress, network } = useAccountSettings(); - return useMemo(() => { - return getAccountProfileInfo( - selectedWallet, - walletNames, - network, - accountAddress - ); - }, [accountAddress, network, walletNames, selectedWallet]); + const { + accountAddress: accountsettingsAddress, + network, + } = useAccountSettings(); + + const { + accountAddress, + accountColor, + accountENS, + accountImage, + accountName, + accountSymbol, + } = useMemo( + () => + getAccountProfileInfo( + selectedWallet, + walletNames, + network, + accountsettingsAddress + ), + [accountsettingsAddress, network, selectedWallet, walletNames] + ); + + return { + accountAddress, + accountColor, + accountENS, + accountImage, + accountName, + accountSymbol, + }; } diff --git a/src/hooks/useAsset.js b/src/hooks/useAsset.js index 49e0d10ac56..3ad3bb2eb35 100644 --- a/src/hooks/useAsset.js +++ b/src/hooks/useAsset.js @@ -5,11 +5,16 @@ import { AssetTypes } from '@rainbow-me/entities'; // To fetch an asset from account assets, // generic assets, and uniqueTokens -export default function useAsset(asset) { +export default function useAsset( + asset, + { revalidateCollectibleInBackground = false } = {} +) { const accountAsset = useAccountAsset( asset?.uniqueId || asset?.mainnet_address || asset?.address ); - const uniqueToken = useCollectible(asset); + const uniqueToken = useCollectible(asset, { + revalidateInBackground: revalidateCollectibleInBackground, + }); return useMemo(() => { if (!asset) return null; diff --git a/src/hooks/useCollectible.js b/src/hooks/useCollectible.js index aac4ef78368..7f7c298556e 100644 --- a/src/hooks/useCollectible.js +++ b/src/hooks/useCollectible.js @@ -1,16 +1,75 @@ -import { useMemo } from 'react'; -import { useSelector } from 'react-redux'; +import { useEffect, useMemo } from 'react'; +import { useQueryClient } from 'react-query'; +import { useDispatch, useSelector } from 'react-redux'; +import { useAdditionalRecyclerAssetListData } from '../components/asset-list/RecyclerAssetList2/core/Contexts'; +import useAccountSettings from './useAccountSettings'; +import { uniqueTokensQueryKey } from './useFetchUniqueTokens'; +import { revalidateUniqueToken } from '@rainbow-me/redux/uniqueTokens'; -export default function useCollectible(asset) { - const uniqueTokens = useSelector( +export default function useCollectible( + initialAsset, + { revalidateInBackground = false } = {} +) { + // Retrieve the unique tokens belonging to the current account address. + const selfUniqueTokens = useSelector( ({ uniqueTokens: { uniqueTokens } }) => uniqueTokens ); + const { accountAddress } = useAccountSettings(); + // Retrieve the unique tokens belonging to the targeted asset list account + // (e.g. viewing another persons ENS profile via `ProfileSheet`) + // "External" unique tokens are tokens that belong to another address (not the current account address). + const { externalAddress } = useAdditionalRecyclerAssetListData(0); + const queryClient = useQueryClient(); + const externalUniqueTokens = useMemo(() => { + return ( + queryClient.getQueryData( + uniqueTokensQueryKey({ address: externalAddress }) + ) || [] + ); + }, [queryClient, externalAddress]); + const isExternal = + Boolean(externalAddress) && externalAddress !== accountAddress; + // Use the appropriate tokens based on if the user is viewing the + // current accounts tokens, or external tokens (e.g. ProfileSheet) + const uniqueTokens = useMemo( + () => (isExternal ? externalUniqueTokens : selfUniqueTokens), + [externalUniqueTokens, isExternal, selfUniqueTokens] + ); - return useMemo(() => { + const asset = useMemo(() => { let matched = uniqueTokens.find( - uniqueToken => uniqueToken.uniqueId === asset?.uniqueId + uniqueToken => uniqueToken.uniqueId === initialAsset?.uniqueId ); + return matched || initialAsset; + }, [initialAsset, uniqueTokens]); + + useRevalidateInBackground({ + contractAddress: asset?.asset_contract?.address, + enabled: revalidateInBackground && !isExternal, + isExternal, + tokenId: asset?.id, + }); + + return { ...asset, isExternal }; +} - return matched || asset; - }, [asset, uniqueTokens]); +function useRevalidateInBackground({ + contractAddress, + tokenId, + isExternal, + enabled, +}) { + const dispatch = useDispatch(); + useEffect(() => { + // If `forceUpdate` is truthy, we want to force refresh the metadata from OpenSea & + // update in the background. Useful for refreshing ENS metadata to resolve "Unknown ENS name". + if (enabled && contractAddress) { + // Revalidate the updated asset in the background & update the `uniqueTokens` cache. + dispatch( + revalidateUniqueToken(contractAddress, tokenId, { + forceUpdate: true, + }) + ); + } + }, [contractAddress, dispatch, enabled, isExternal, tokenId]); } diff --git a/src/hooks/useDeleteWallet.ts b/src/hooks/useDeleteWallet.ts new file mode 100644 index 00000000000..d330d4fc353 --- /dev/null +++ b/src/hooks/useDeleteWallet.ts @@ -0,0 +1,55 @@ +import { toLower } from 'lodash'; +import { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { removeWalletData } from '@rainbow-me/handlers/localstorage/removeWallet'; +import { useWallets } from '@rainbow-me/hooks'; +import { walletsUpdate } from '@rainbow-me/redux/wallets'; + +export default function useDeleteWallet({ + address: primaryAddress, +}: { + address?: string; +}) { + const dispatch = useDispatch(); + + const { wallets } = useWallets(); + + const [watchingWalletId] = useMemo(() => { + return ( + Object.entries(wallets || {}).find(([_, wallet]: [string, any]) => + wallet.addresses.some(({ address }: any) => address === primaryAddress) + ) || ['', ''] + ); + }, [primaryAddress, wallets]); + + const deleteWallet = useCallback(() => { + const newWallets = { + ...wallets, + [watchingWalletId]: { + ...wallets[watchingWalletId], + addresses: wallets[ + watchingWalletId + ].addresses.map((account: { address: string }) => + toLower(account.address) === toLower(primaryAddress) + ? { ...account, visible: false } + : account + ), + }, + }; + // If there are no visible wallets + // then delete the wallet + const visibleAddresses = newWallets[watchingWalletId].addresses.filter( + (account: { visible: boolean }) => account.visible + ); + if (visibleAddresses.length === 0) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete newWallets[watchingWalletId]; + dispatch(walletsUpdate(newWallets)); + } else { + dispatch(walletsUpdate(newWallets)); + } + removeWalletData(primaryAddress); + }, [dispatch, primaryAddress, wallets, watchingWalletId]); + + return deleteWallet; +} diff --git a/src/hooks/useENSModifiedRegistration.ts b/src/hooks/useENSModifiedRegistration.ts new file mode 100644 index 00000000000..b2decda32f3 --- /dev/null +++ b/src/hooks/useENSModifiedRegistration.ts @@ -0,0 +1,196 @@ +import { differenceWith, isEqual } from 'lodash'; +import { useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import useENSProfileRecords from './useENSProfileRecords'; +import useENSRegistration from './useENSRegistration'; +import { usePrevious } from '.'; +import { Records, UniqueAsset } from '@rainbow-me/entities'; +import { REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; +import * as ensRedux from '@rainbow-me/redux/ensRegistration'; +import { AppState } from '@rainbow-me/redux/store'; +import { + getENSNFTAvatarUrl, + isENSNFTRecord, + parseENSNFTRecord, +} from '@rainbow-me/utils'; + +const getImageUrl = ( + key: 'avatar' | 'cover', + records: Records, + changedRecords: Records, + uniqueTokens: UniqueAsset[], + defaultValue?: string | null, + mode?: keyof typeof REGISTRATION_MODES +) => { + const recordValue = records?.[key]; + let imageUrl = + getENSNFTAvatarUrl(uniqueTokens, records?.[key]) || defaultValue; + + if (changedRecords[key] === '' && mode === REGISTRATION_MODES.EDIT) { + // If the image has been removed, update accordingly. + imageUrl = ''; + } else if (recordValue) { + const isNFT = isENSNFTRecord(recordValue); + if (isNFT) { + const { contractAddress, tokenId } = parseENSNFTRecord( + records?.[key] || '' + ); + const uniqueToken = uniqueTokens.find( + token => + token.asset_contract.address === contractAddress && + token.id === tokenId + ); + if (uniqueToken?.image_url) { + imageUrl = uniqueToken?.image_url; + } else if (uniqueToken?.image_thumbnail_url) { + imageUrl = uniqueToken?.image_thumbnail_url; + } + } else if ( + recordValue?.startsWith('http') || + recordValue?.startsWith('file') || + ((recordValue?.startsWith('/') || recordValue?.startsWith('~')) && + !recordValue?.match(/^\/(ipfs|ipns)/)) + ) { + imageUrl = recordValue; + } + } + return imageUrl; +}; + +export default function useENSModifiedRegistration({ + setInitialRecordsWhenInEditMode = false, + modifyChangedRecords = false, +}: { + /** When true, an update to `initialRecords` will be triggered when the flow is in "edit mode". */ + setInitialRecordsWhenInEditMode?: boolean; + modifyChangedRecords?: boolean; +} = {}) { + const dispatch = useDispatch(); + const { records, initialRecords, name, mode } = useENSRegistration(); + + const uniqueTokens = useSelector( + ({ uniqueTokens }: AppState) => uniqueTokens.uniqueTokens + ); + const profileQuery = useENSProfileRecords(name, { + enabled: + mode === REGISTRATION_MODES.EDIT || + mode === REGISTRATION_MODES.RENEW || + mode === REGISTRATION_MODES.SET_NAME, + }); + + useEffect(() => { + if ( + setInitialRecordsWhenInEditMode && + mode === REGISTRATION_MODES.EDIT && + profileQuery.isSuccess + ) { + const initialRecords = { + ...profileQuery.data?.records, + ...profileQuery.data?.coinAddresses, + } as Records; + dispatch(ensRedux.setInitialRecords(initialRecords)); + } + }, [ + dispatch, + mode, + profileQuery.data?.coinAddresses, + profileQuery.data?.records, + profileQuery.isSuccess, + setInitialRecordsWhenInEditMode, + ]); + + // Derive the records that should be added or removed from the profile + // (these should be used for SET_TEXT txns instead of `records` to save + // gas). + const changedRecords = useMemo(() => { + const entriesToChange = differenceWith( + Object.entries(records), + Object.entries(initialRecords), + isEqual + ) as [keyof Records, string][]; + + const changedRecords = entriesToChange.reduce( + (recordsToAdd: Partial, [key, value]) => ({ + ...recordsToAdd, + ...(value ? { [key]: value } : {}), + }), + {} + ); + + const recordKeysWithValue = (Object.keys( + records + ) as (keyof Records)[]).filter((key: keyof Records) => { + return Boolean(records[key]); + }); + + const keysToRemove = differenceWith( + Object.keys(initialRecords), + recordKeysWithValue, + isEqual + ) as (keyof Records)[]; + + const removedRecords = keysToRemove.reduce( + (recordsToAdd: Partial, key) => ({ + ...recordsToAdd, + [key]: '', + }), + {} + ); + + return { + ...changedRecords, + ...removedRecords, + }; + }, [initialRecords, records]); + + const prevChangedRecords = usePrevious(changedRecords); + useEffect(() => { + if ( + modifyChangedRecords && + JSON.stringify(prevChangedRecords || {}) !== + JSON.stringify(changedRecords) + ) { + dispatch(ensRedux.setChangedRecords(changedRecords)); + } + }, [changedRecords, dispatch, modifyChangedRecords, prevChangedRecords]); + + // Since `records.avatar` is not a reliable source for an avatar URL + // (the avatar can be an NFT), then if the avatar is an NFT, we will + // parse it to obtain the URL. + const images = useMemo(() => { + const avatarUrl = getImageUrl( + 'avatar', + records, + changedRecords, + uniqueTokens, + profileQuery.data?.images.avatarUrl, + mode + ); + const coverUrl = getImageUrl( + 'cover', + records, + changedRecords, + uniqueTokens, + profileQuery.data?.images.coverUrl, + mode + ); + + return { + avatarUrl, + coverUrl, + }; + }, [ + profileQuery.data?.images.avatarUrl, + profileQuery.data?.images.coverUrl, + records, + uniqueTokens, + changedRecords, + mode, + ]); + + return { + changedRecords, + images, + profileQuery, + }; +} diff --git a/src/hooks/useENSPendingRegistrations.tsx b/src/hooks/useENSPendingRegistrations.tsx new file mode 100644 index 00000000000..8b6f4e7cca9 --- /dev/null +++ b/src/hooks/useENSPendingRegistrations.tsx @@ -0,0 +1,73 @@ +import { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useAccountSettings, useENSRegistration } from '.'; +import { ENSRegistrationState } from '@rainbow-me/entities'; +import { removeExpiredRegistrations } from '@rainbow-me/redux/ensRegistration'; +import { AppState } from '@rainbow-me/redux/store'; +import { getENSNFTAvatarUrl } from '@rainbow-me/utils'; + +export default function useENSPendingRegistrations() { + const { accountAddress } = useAccountSettings(); + const { removeRegistrationByName } = useENSRegistration(); + const dispatch = useDispatch(); + + const { pendingRegistrations, accountRegistrations } = useSelector( + ({ ensRegistration }: AppState) => { + const { registrations } = ensRegistration as ENSRegistrationState; + const accountRegistrations = + registrations?.[accountAddress.toLowerCase()] || []; + const registrationsArray = Object.values(accountRegistrations); + + const pendingRegistrations = registrationsArray + .filter( + registration => + !registration?.registerTransactionHash && + registration?.commitTransactionHash + ) + .sort( + (a, b) => + (a?.commitTransactionConfirmedAt || 0) - + (b?.commitTransactionConfirmedAt || 0) + ); + + return { accountRegistrations, pendingRegistrations }; + } + ); + + const uniqueTokens = useSelector( + ({ uniqueTokens }: AppState) => uniqueTokens.uniqueTokens + ); + const registrationImages = useMemo(() => { + const registrationImagesArray = pendingRegistrations?.map( + ({ name, records }) => { + const avatarUrl = getENSNFTAvatarUrl(uniqueTokens, records?.avatar); + return { + avatarUrl, + name, + }; + } + ); + const registrationImages: { [name: string]: string | undefined } = {}; + registrationImagesArray.forEach( + ({ name, avatarUrl }) => (registrationImages[name] = avatarUrl) + ); + return registrationImages; + }, [pendingRegistrations, uniqueTokens]); + + const removeRegistration = useCallback( + name => removeRegistrationByName(name), + [removeRegistrationByName] + ); + + const refreshRegistrations = useCallback(() => { + dispatch(removeExpiredRegistrations()); + }, [dispatch]); + + return { + accountRegistrations, + pendingRegistrations, + registrationImages, + removeExpiredRegistrations: refreshRegistrations, + removeRegistrationByName: removeRegistration, + }; +} diff --git a/src/hooks/useENSProfile.ts b/src/hooks/useENSProfile.ts index 4889a7789dd..324cdd92059 100644 --- a/src/hooks/useENSProfile.ts +++ b/src/hooks/useENSProfile.ts @@ -1,28 +1,65 @@ -import { useSelector } from 'react-redux'; -import { useAccountSettings } from '.'; -import { ENSRegistrationState } from '@rainbow-me/entities'; -import { AppState } from '@rainbow-me/redux/store'; +import { useQuery } from 'react-query'; +import useAccountSettings from './useAccountSettings'; +import useWallets from './useWallets'; +import { fetchProfile } from '@rainbow-me/handlers/ens'; +import { getProfile, saveProfile } from '@rainbow-me/handlers/localstorage/ens'; +import { queryClient } from '@rainbow-me/react-query/queryClient'; +import { QueryConfig, UseQueryData } from '@rainbow-me/react-query/types'; -export default function useENSProfile() { - const { accountAddress } = useAccountSettings(); - const { records, name, registrationParameters } = useSelector( - ({ ensRegistration }: AppState) => { - const { - currentRegistrationName, - registrations, - } = ensRegistration as ENSRegistrationState; - const registrationParameters = - registrations?.[accountAddress?.toLowerCase()]?.[ - currentRegistrationName - ] || {}; - const records = registrationParameters?.records || []; - return { name: currentRegistrationName, records, registrationParameters }; - } +const queryKey = (name: string) => ['ens-profile', name]; + +const STALE_TIME = 10000; + +async function fetchENSProfile({ name }: { name: string }) { + const cachedProfile = await getProfile(name); + if (cachedProfile) { + queryClient.setQueryData(queryKey(name), cachedProfile); + } + const profile = await fetchProfile(name); + saveProfile(name, profile); + return profile; +} + +export async function prefetchENSProfile({ name }: { name: string }) { + queryClient.prefetchQuery( + queryKey(name), + async () => fetchENSProfile({ name }), + { staleTime: STALE_TIME } ); +} + +export default function useENSProfile( + name: string, + config?: QueryConfig +) { + const { accountAddress } = useAccountSettings(); + const { walletNames } = useWallets(); + const { data, isLoading, isSuccess } = useQuery< + UseQueryData + >(queryKey(name), async () => fetchENSProfile({ name }), { + ...config, + // Data will be stale for 10s to avoid dupe queries + staleTime: STALE_TIME, + }); + + const isOwner = + data?.owner?.address?.toLowerCase() === accountAddress?.toLowerCase(); + + // if a ENS NFT is sent, the ETH coinAddress record doesn't change + // if the user tries to use it to set primary name the tx will go through + // but the name won't be set. Disabling it to avoid these cases + const isSetNameEnabled = + data?.coinAddresses?.ETH?.toLowerCase() === accountAddress?.toLowerCase(); + + const isPrimaryName = + walletNames?.[accountAddress]?.toLowerCase() === name?.toLowerCase(); return { - name, - records, - registrationParameters, + data, + isLoading, + isOwner, + isPrimaryName, + isSetNameEnabled, + isSuccess, }; } diff --git a/src/hooks/useENSProfileForm.ts b/src/hooks/useENSProfileForm.ts deleted file mode 100644 index 14545a5669c..00000000000 --- a/src/hooks/useENSProfileForm.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { isEmpty, omit } from 'lodash'; -import { useCallback, useEffect, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; -import { atom, useRecoilState } from 'recoil'; -import { useAccountSettings, useENSProfile } from '.'; -import { Records } from '@rainbow-me/entities'; -import { textRecordFields } from '@rainbow-me/helpers/ens'; -import { - removeRecordByKey, - updateRecordByKey, - updateRecords, -} from '@rainbow-me/redux/ensRegistration'; - -const selectedFieldsAtom = atom({ - default: [], - key: 'ensProfileForm.selectedFields', -}); - -const valuesAtom = atom({ - default: {}, - key: 'ensProfileForm.values', -}); - -export default function useENSProfileForm({ - defaultFields, -}: { - defaultFields?: any[]; -} = {}) { - const { accountAddress } = useAccountSettings(); - const { name, records } = useENSProfile(); - - const dispatch = useDispatch(); - - const [selectedFields, setSelectedFields] = useRecoilState( - selectedFieldsAtom - ); - useEffect(() => { - // If there are existing records in the global state, then we - // populate with that. - if (!isEmpty(records)) { - // @ts-ignore - setSelectedFields(Object.keys(records).map(key => textRecordFields[key])); - } else { - if (defaultFields) { - setSelectedFields(defaultFields as any); - } - } - }, [name]); // eslint-disable-line react-hooks/exhaustive-deps - - const [values, setValues] = useRecoilState(valuesAtom); - useEffect(() => setValues(records), [name]); // eslint-disable-line react-hooks/exhaustive-deps - - // Set initial records in redux depending on user input (defaultFields) - useEffect(() => { - if (isEmpty(records) && defaultFields) { - const records = defaultFields.reduce((records, field) => { - return { - ...records, - [field.key]: '', - }; - }, {}); - dispatch(updateRecords(accountAddress, records)); - } - }, [accountAddress, defaultFields, dispatch, records, selectedFields]); - - const onAddField = useCallback( - (fieldToAdd, selectedFields) => { - setSelectedFields(selectedFields); - dispatch(updateRecordByKey(accountAddress, fieldToAdd.key, '')); - }, - [accountAddress, dispatch, setSelectedFields] - ); - - const onRemoveField = useCallback( - (fieldToRemove, selectedFields) => { - setSelectedFields(selectedFields); - dispatch(removeRecordByKey(accountAddress, fieldToRemove.key)); - setValues(values => omit(values, fieldToRemove.key) as Records); - }, - [accountAddress, dispatch, setSelectedFields, setValues] - ); - - const onBlurField = useCallback( - ({ key, value }) => { - dispatch(updateRecordByKey(accountAddress, key, value)); - }, - [accountAddress, dispatch] - ); - - const onChangeField = useCallback( - ({ key, value }) => { - setValues(values => ({ ...values, [key]: value })); - }, - [setValues] - ); - - const empty = useMemo(() => !Object.values(values).some(Boolean), [values]); - - return { - isEmpty: empty, - onAddField, - onBlurField, - onChangeField, - onRemoveField, - selectedFields, - values, - }; -} diff --git a/src/hooks/useENSProfileImages.ts b/src/hooks/useENSProfileImages.ts new file mode 100644 index 00000000000..14f5232c62d --- /dev/null +++ b/src/hooks/useENSProfileImages.ts @@ -0,0 +1,50 @@ +import { useQuery } from 'react-query'; +import { fetchImages } from '@rainbow-me/handlers/ens'; +import { + getProfileImages, + saveProfileImages, +} from '@rainbow-me/handlers/localstorage/ens'; +import { queryClient } from '@rainbow-me/react-query/queryClient'; +import { QueryConfig, UseQueryData } from '@rainbow-me/react-query/types'; + +export const ensProfileImagesQueryKey = (name: string) => [ + 'ens-profile-images', + name, +]; + +const STALE_TIME = 10000; + +async function fetchENSProfileImages({ name }: { name: string }) { + const cachedImages = await getProfileImages(name); + if (cachedImages) { + queryClient.setQueryData(ensProfileImagesQueryKey(name), cachedImages); + } + const images = await fetchImages(name); + saveProfileImages(name, images); + return images; +} + +export async function prefetchENSProfileImages({ name }: { name: string }) { + queryClient.prefetchQuery( + ensProfileImagesQueryKey(name), + async () => fetchENSProfileImages({ name }), + { staleTime: STALE_TIME } + ); +} + +export default function useENSProfileImages( + name: string, + config?: QueryConfig +) { + const { data, isFetched } = useQuery>( + ensProfileImagesQueryKey(name), + async () => fetchENSProfileImages({ name }), + { + ...config, + // Data will be stale for 10s to avoid dupe queries + staleTime: STALE_TIME, + } + ); + + return { data, isFetched }; +} diff --git a/src/hooks/useENSProfileRecords.ts b/src/hooks/useENSProfileRecords.ts new file mode 100644 index 00000000000..c570fb2df99 --- /dev/null +++ b/src/hooks/useENSProfileRecords.ts @@ -0,0 +1,39 @@ +import { useQuery, useQueryClient } from 'react-query'; +import { fetchProfileRecords } from '@rainbow-me/handlers/ens'; +import { + getProfileRecords, + saveProfileRecords, +} from '@rainbow-me/handlers/localstorage/ens'; +import { QueryConfig, UseQueryData } from '@rainbow-me/react-query/types'; + +const queryKey = (name: string) => ['ens-profile-records', name]; + +const STALE_TIME = 10000; + +export default function useENSProfileRecords( + name: string, + config?: QueryConfig +) { + const queryClient = useQueryClient(); + const { data, isLoading, isSuccess } = useQuery< + UseQueryData + >( + queryKey(name), + async () => { + const cachedProfile = await getProfileRecords(name); + if (cachedProfile) { + queryClient.setQueryData(queryKey(name), cachedProfile); + } + const profileRecords = await fetchProfileRecords(name); + saveProfileRecords(name, profileRecords); + return profileRecords; + }, + { + ...config, + // Data will be stale for 10s to avoid dupe queries + staleTime: STALE_TIME, + } + ); + + return { data, isLoading, isSuccess }; +} diff --git a/src/hooks/useENSRecordDisplayProperties.tsx b/src/hooks/useENSRecordDisplayProperties.tsx new file mode 100644 index 00000000000..0a521a897d0 --- /dev/null +++ b/src/hooks/useENSRecordDisplayProperties.tsx @@ -0,0 +1,252 @@ +import lang from 'i18n-js'; +import { upperFirst } from 'lodash'; +import React, { useCallback, useMemo } from 'react'; +import { Linking } from 'react-native'; +import { ContextMenuButton } from 'react-native-ios-context-menu'; +import URL from 'url-parse'; +import useClipboard from './useClipboard'; +import useENSRegistration from './useENSRegistration'; +import { + ENS_RECORDS, + REGISTRATION_MODES, + textRecordFields, +} from '@rainbow-me/helpers/ens'; +import { useNavigation } from '@rainbow-me/navigation'; +import Routes from '@rainbow-me/routes'; +import { showActionSheetWithOptions } from '@rainbow-me/utils'; +import { formatAddressForDisplay } from '@rainbow-me/utils/abbreviations'; + +const imageKeyMap = { + [ENS_RECORDS.avatar]: 'avatarUrl', + [ENS_RECORDS.cover]: 'coverUrl', +} as { + [key: string]: 'avatarUrl' | 'coverUrl'; +}; + +const icons = { + [ENS_RECORDS.twitter]: 'twitter', + [ENS_RECORDS.github]: 'github', + [ENS_RECORDS.instagram]: 'instagram', + [ENS_RECORDS.snapchat]: 'snapchat', + [ENS_RECORDS.discord]: 'discord', + [ENS_RECORDS.reddit]: 'reddit', + [ENS_RECORDS.telegram]: 'telegram', + [ENS_RECORDS.DOGE]: 'dogeCoin', + [ENS_RECORDS.BTC]: 'btcCoin', + [ENS_RECORDS.LTC]: 'ltcCoin', +} as { [key: string]: string }; + +const links = { + [ENS_RECORDS.twitter]: 'https://twitter.com/', + [ENS_RECORDS.github]: 'https://github.com/', + [ENS_RECORDS.instagram]: 'https://intagram.com/', + [ENS_RECORDS.reddit]: 'https://reddit.com/', + [ENS_RECORDS.telegram]: 'https://telegram.com/', +} as { [key: string]: string }; + +export default function useENSRecordDisplayProperties({ + allowEdit, + ensName, + key: recordKey, + value: recordValue, + type, +}: { + allowEdit?: boolean; + ensName?: string; + key: string; + value: string; + type: 'address' | 'record'; +}) { + const isImageValue = useMemo( + () => Object.keys(imageKeyMap).includes(recordKey), + [recordKey] + ); + + const isUrlRecord = useMemo( + () => + [ENS_RECORDS.url, ENS_RECORDS.website].includes(recordKey as ENS_RECORDS), + [recordKey] + ); + const isUrlValue = useMemo(() => recordValue.match(/^http/), [recordValue]); + + const url = useMemo(() => { + if (isUrlValue || isUrlRecord) { + return recordValue.match(/^http/) + ? recordValue + : `https://${recordValue}`; + } + if (links[recordKey]) { + return `${links[recordKey]}${recordValue.replace('@', '')}`; + } + }, [isUrlRecord, isUrlValue, recordKey, recordValue]); + + const { displayUrl, displayUrlUsername } = useMemo(() => { + const urlObj = url ? new URL(url) : { hostname: '', pathname: '' }; + return { + displayUrl: urlObj?.hostname?.replace(/^www\./, ''), + displayUrlUsername: urlObj?.pathname?.replace('/', ''), + }; + }, [url]); + + const label = useMemo(() => { + // @ts-expect-error + if (textRecordFields[recordKey]?.label) { + // @ts-expect-error + return textRecordFields[recordKey].label; + } + if (recordKey.includes('.')) { + return recordKey; + } + return `${upperFirst(recordKey)}${type === 'address' ? ' address' : ''}`; + }, [recordKey, type]); + + const value = useMemo(() => { + if (isUrlRecord && displayUrl) { + return `􀤆 ${displayUrl}`; + } + if (isUrlValue && displayUrlUsername) { + return displayUrlUsername; + } + if (recordKey === ENS_RECORDS.email) { + return `􀍕 ${recordValue}`; + } + if (type === 'address') { + return formatAddressForDisplay(recordValue, 4, 4) || ''; + } + return recordValue; + }, [ + displayUrl, + displayUrlUsername, + isUrlRecord, + isUrlValue, + recordKey, + recordValue, + type, + ]); + + const icon = useMemo(() => icons[recordKey], [recordKey]); + + const menuItems = useMemo(() => { + return [ + allowEdit && + type === 'record' && + Object.values(ENS_RECORDS).includes(recordKey as ENS_RECORDS) + ? { + actionKey: 'edit', + actionTitle: lang.t('expanded_state.unique_expanded.edit'), + icon: { + iconType: 'SYSTEM', + iconValue: 'square.and.pencil', + }, + } + : undefined, + url + ? { + actionKey: 'open-url', + actionTitle: lang.t( + 'expanded_state.unique_expanded.view_on_platform', + { platform: isUrlValue ? 'Web' : label } + ), + discoverabilityTitle: displayUrl, + icon: { + iconType: 'SYSTEM', + iconValue: 'arrow.up.forward.app.fill', + }, + } + : undefined, + { + actionKey: 'copy', + actionTitle: lang.t('expanded_state.unique_expanded.copy'), + discoverabilityTitle: displayUrl || recordValue, + icon: { + iconType: 'SYSTEM', + iconValue: 'square.on.square', + }, + }, + ].filter(Boolean); + }, [ + allowEdit, + displayUrl, + isUrlValue, + label, + recordKey, + recordValue, + type, + url, + ]); + + const { navigate } = useNavigation(); + const { setClipboard } = useClipboard(); + const { startRegistration } = useENSRegistration(); + const handlePressMenuItem = useCallback( + ({ nativeEvent: { actionKey } }) => { + if (actionKey === 'open-url' && url) { + Linking.openURL(url); + } + if (actionKey === 'copy') { + setClipboard(recordValue); + } + if (actionKey === 'edit' && ensName) { + startRegistration(ensName, REGISTRATION_MODES.EDIT); + navigate(Routes.REGISTER_ENS_NAVIGATOR, { + autoFocusKey: recordKey, + ensName, + mode: REGISTRATION_MODES.EDIT, + }); + } + }, + [ + ensName, + navigate, + recordKey, + recordValue, + setClipboard, + startRegistration, + url, + ] + ); + + const handleAndroidPress = useCallback(() => { + const actionSheetOptions = menuItems + .map(item => item?.actionTitle) + .filter(Boolean) as any; + + showActionSheetWithOptions( + { + options: actionSheetOptions, + }, + async (buttonIndex: number) => { + const actionKey = menuItems[buttonIndex]?.actionKey; + handlePressMenuItem({ nativeEvent: { actionKey } }); + } + ); + }, [handlePressMenuItem, menuItems]); + + const Button = useCallback( + ({ children, ...props }) => ( + + {children} + + ), + [handleAndroidPress, handlePressMenuItem, isImageValue, menuItems] + ); + + return { + ContextMenuButton: Button, + icon, + isImageValue, + isUrlValue, + label, + url, + value, + }; +} diff --git a/src/hooks/useENSRegistration.ts b/src/hooks/useENSRegistration.ts index 93f4e3cbd8b..7fd56efee24 100644 --- a/src/hooks/useENSRegistration.ts +++ b/src/hooks/useENSRegistration.ts @@ -1,99 +1,80 @@ -import { format } from 'date-fns'; import { useCallback, useMemo } from 'react'; -import { useQuery } from 'react-query'; +import { useDispatch, useSelector } from 'react-redux'; import { useAccountSettings } from '.'; -import { fetchRegistrationDate } from '@rainbow-me/handlers/ens'; -import { - formatRentPrice, - getAvailable, - getNameExpires, - getRentPrice, -} from '@rainbow-me/helpers/ens'; -import { Network } from '@rainbow-me/helpers/networkTypes'; -import { timeUnits } from '@rainbow-me/references'; -import { ethereumUtils, validateENS } from '@rainbow-me/utils'; +import { Records } from '@rainbow-me/entities'; +import { REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; +import * as ensRedux from '@rainbow-me/redux/ensRegistration'; +import { AppState } from '@rainbow-me/redux/store'; -const formatTime = (timestamp: string, abbreviated: boolean = true) => { - const style = abbreviated ? 'MMM d, y' : 'MMMM d, y'; - return format(new Date(Number(timestamp) * 1000), style); -}; +export default function useENSRegistration() { + const { accountAddress } = useAccountSettings(); -export default function useENSRegistration({ - duration = 1, - name, -}: { - duration?: number; - name: string; -}) { - const { nativeCurrency } = useAccountSettings(); - const isValidLength = useMemo(() => name.length > 2, [name.length]); - - const getRegistrationValues = useCallback(async () => { - const ensValidation = validateENS(`${name}.eth`, { - includeSubdomains: false, - }); - - if (!ensValidation.valid) { + const registrationParameters = useSelector( + ({ ensRegistration }: AppState) => { return { - code: ensValidation.code, - hint: ensValidation.hint, - valid: false, + ...ensRegistration.registrations?.[accountAddress?.toLowerCase()]?.[ + ensRegistration.currentRegistrationName + ], + currentRegistrationName: ensRegistration.currentRegistrationName, }; } + ); - const isAvailable = await getAvailable(name); - if (isAvailable) { - const rentPrice = await getRentPrice( - name, - duration * timeUnits.secs.year - ); - const nativeAssetPrice = ethereumUtils.getPriceOfNativeAssetForNetwork( - Network.mainnet - ); - const formattedRentPrice = formatRentPrice( - rentPrice, - duration, - nativeCurrency, - nativeAssetPrice - ); - - return { - available: isAvailable, - rentPrice: formattedRentPrice, - valid: true, - }; - } else { - // we need the expiration and registration date when is not available - const registrationDate = await fetchRegistrationDate(name + '.eth'); - const nameExpires = await getNameExpires(name); - const formattedRegistrationDate = formatTime(registrationDate, false); - const formattedExpirationDate = formatTime(nameExpires); - - return { - available: isAvailable, - expirationDate: formattedExpirationDate, - registrationDate: formattedRegistrationDate, - valid: true, - }; - } - }, [duration, name, nativeCurrency]); + const { mode, name, initialRecords, records } = useMemo( + () => ({ + initialRecords: registrationParameters.initialRecords || {}, + mode: registrationParameters.mode, + name: registrationParameters.currentRegistrationName, + records: registrationParameters.records || {}, + }), + [ + registrationParameters.initialRecords, + registrationParameters.mode, + registrationParameters.currentRegistrationName, + registrationParameters.records, + ] + ); - const { data, status, isIdle, isLoading } = useQuery( - ['getRegistrationValues', [duration, name, nativeCurrency]], - getRegistrationValues, - { enabled: isValidLength, retry: 0, staleTime: Infinity } + const dispatch = useDispatch(); + const removeRecordByKey = useCallback( + (key: string) => dispatch(ensRedux.removeRecordByKey(key)), + [dispatch] + ); + const startRegistration = useCallback( + (name: string, mode: keyof typeof REGISTRATION_MODES) => + dispatch(ensRedux.startRegistration(name, mode)), + [dispatch] + ); + const updateRecordByKey = useCallback( + (key: string, value: string) => + dispatch(ensRedux.updateRecordByKey(key, value)), + [dispatch] + ); + const updateRecords = useCallback( + (records: Records) => dispatch(ensRedux.updateRecords(records)), + [dispatch] + ); + const clearCurrentRegistrationName = useCallback( + () => dispatch(ensRedux.clearCurrentRegistrationName()), + [dispatch] ); - const isAvailable = status === 'success' && data?.available === true; - const isRegistered = status === 'success' && data?.available === false; - const isInvalid = status === 'success' && !data?.valid; + const removeRegistrationByName = useCallback( + (name: string) => dispatch(ensRedux.removeRegistrationByName(name)), + [dispatch] + ); return { - data, - isAvailable, - isIdle, - isInvalid, - isLoading, - isRegistered, + clearCurrentRegistrationName, + initialRecords, + mode, + name, + records, + registrationParameters, + removeRecordByKey, + removeRegistrationByName, + startRegistration, + updateRecordByKey, + updateRecords, }; } diff --git a/src/hooks/useENSRegistrationActionHandler.ts b/src/hooks/useENSRegistrationActionHandler.ts new file mode 100644 index 00000000000..a769c6e2eaa --- /dev/null +++ b/src/hooks/useENSRegistrationActionHandler.ts @@ -0,0 +1,338 @@ +import { useNavigation } from '@react-navigation/core'; +import { useCallback, useMemo } from 'react'; +import { Image } from 'react-native-image-crop-picker'; +import { useDispatch } from 'react-redux'; +import { useRecoilValue } from 'recoil'; +import { avatarMetadataAtom } from '../components/ens-registration/RegistrationAvatar/RegistrationAvatar'; +import { coverMetadataAtom } from '../components/ens-registration/RegistrationCover/RegistrationCover'; +import { ENSActionParameters, RapActionTypes } from '../raps/common'; +import usePendingTransactions from './usePendingTransactions'; +import { useAccountSettings, useCurrentNonce, useENSRegistration } from '.'; +import { Records, RegistrationParameters } from '@rainbow-me/entities'; +import { fetchResolver } from '@rainbow-me/handlers/ens'; +import { uploadImage } from '@rainbow-me/handlers/pinata'; +import { + ENS_DOMAIN, + generateSalt, + getRentPrice, + REGISTRATION_STEPS, +} from '@rainbow-me/helpers/ens'; +import { loadWallet } from '@rainbow-me/model/wallet'; +import { executeRap } from '@rainbow-me/raps'; +import { saveCommitRegistrationParameters } from '@rainbow-me/redux/ensRegistration'; +import { timeUnits } from '@rainbow-me/references'; +import Routes from '@rainbow-me/routes'; +import { logger } from '@rainbow-me/utils'; + +const formatENSActionParams = ( + registrationParameters: RegistrationParameters +): ENSActionParameters => { + return { + duration: registrationParameters?.duration, + name: registrationParameters?.name, + ownerAddress: registrationParameters?.ownerAddress, + records: registrationParameters?.records, + rentPrice: registrationParameters?.rentPrice, + salt: registrationParameters?.salt, + setReverseRecord: registrationParameters?.setReverseRecord, + }; +}; + +export default function useENSRegistrationActionHandler( + { + sendReverseRecord, + yearsDuration, + step: registrationStep, + }: { + yearsDuration: number; + sendReverseRecord: boolean; + step: keyof typeof REGISTRATION_STEPS; + } = {} as any +) { + const dispatch = useDispatch(); + const { accountAddress, network } = useAccountSettings(); + const getNextNonce = useCurrentNonce(accountAddress, network); + const { registrationParameters } = useENSRegistration(); + const { navigate } = useNavigation(); + const { getPendingTransactionByHash } = usePendingTransactions(); + + const avatarMetadata = useRecoilValue(avatarMetadataAtom); + const coverMetadata = useRecoilValue(coverMetadataAtom); + + const duration = yearsDuration * timeUnits.secs.year; + + // actions + const commitAction = useCallback( + async (callback: () => void) => { + const wallet = await loadWallet(); + if (!wallet) { + return; + } + const salt = generateSalt(); + + const [nonce, rentPrice] = await Promise.all([ + getNextNonce(), + getRentPrice( + registrationParameters.name.replace(ENS_DOMAIN, ''), + duration + ), + ]); + + const commitEnsRegistrationParameters: ENSActionParameters = { + ...formatENSActionParams(registrationParameters), + duration, + nonce, + ownerAddress: accountAddress, + records: registrationParameters.changedRecords, + rentPrice: rentPrice.toString(), + salt, + }; + + await executeRap( + wallet, + RapActionTypes.commitENS, + commitEnsRegistrationParameters, + callback + ); + }, + [getNextNonce, registrationParameters, duration, accountAddress] + ); + + const speedUpCommitAction = useCallback( + async (accentColor: string) => { + // we want to speed up the last commit tx sent + const commitTransactionHash = + registrationParameters?.commitTransactionHash; + const saveCommitTransactionHash = (hash: string) => { + dispatch( + saveCommitRegistrationParameters({ + commitTransactionHash: hash, + }) + ); + }; + const tx = getPendingTransactionByHash(commitTransactionHash || ''); + commitTransactionHash && + tx && + navigate(Routes.SPEED_UP_AND_CANCEL_SHEET, { + accentColor, + onSendTransactionCallback: saveCommitTransactionHash, + tx, + type: 'speed_up', + }); + }, + [ + dispatch, + getPendingTransactionByHash, + navigate, + registrationParameters?.commitTransactionHash, + ] + ); + + const registerAction = useCallback( + async (callback: () => void) => { + const { + name, + duration, + } = registrationParameters as RegistrationParameters; + + const wallet = await loadWallet(); + if (!wallet) { + return; + } + + const [nonce, rentPrice, changedRecords] = await Promise.all([ + getNextNonce(), + getRentPrice(name.replace(ENS_DOMAIN, ''), duration), + uploadRecordImages(registrationParameters.changedRecords, { + avatar: avatarMetadata, + cover: coverMetadata, + }), + ]); + + const registerEnsRegistrationParameters: ENSActionParameters = { + ...formatENSActionParams(registrationParameters), + duration, + nonce, + ownerAddress: accountAddress, + records: changedRecords, + rentPrice: rentPrice.toString(), + setReverseRecord: sendReverseRecord, + }; + + await executeRap( + wallet, + RapActionTypes.registerENS, + registerEnsRegistrationParameters, + callback + ); + }, + [ + accountAddress, + avatarMetadata, + coverMetadata, + getNextNonce, + registrationParameters, + sendReverseRecord, + ] + ); + + const renewAction = useCallback( + async (callback: () => void) => { + const { name } = registrationParameters as RegistrationParameters; + + const wallet = await loadWallet(); + if (!wallet) { + return; + } + + const nonce = await getNextNonce(); + const rentPrice = await getRentPrice( + name.replace(ENS_DOMAIN, ''), + duration + ); + + const registerEnsRegistrationParameters: ENSActionParameters = { + ...formatENSActionParams(registrationParameters), + duration, + nonce, + rentPrice: rentPrice.toString(), + }; + + await executeRap( + wallet, + RapActionTypes.renewENS, + registerEnsRegistrationParameters, + callback + ); + }, + [duration, getNextNonce, registrationParameters] + ); + + const setNameAction = useCallback( + async (callback: () => void) => { + const { name } = registrationParameters as RegistrationParameters; + + const wallet = await loadWallet(); + if (!wallet) { + return; + } + + const nonce = await getNextNonce(); + + const registerEnsRegistrationParameters: ENSActionParameters = { + ...formatENSActionParams(registrationParameters), + name, + nonce, + ownerAddress: accountAddress, + }; + + await executeRap( + wallet, + RapActionTypes.setNameENS, + registerEnsRegistrationParameters, + callback + ); + }, + [accountAddress, getNextNonce, registrationParameters] + ); + + const setRecordsAction = useCallback( + async (callback: () => void) => { + const wallet = await loadWallet(); + if (!wallet) { + return; + } + + const [nonce, changedRecords, resolver] = await Promise.all([ + getNextNonce(), + uploadRecordImages(registrationParameters.changedRecords, { + avatar: avatarMetadata, + cover: coverMetadata, + }), + fetchResolver(registrationParameters.name), + ]); + + const setRecordsEnsRegistrationParameters: ENSActionParameters = { + ...formatENSActionParams(registrationParameters), + nonce, + ownerAddress: accountAddress, + records: changedRecords, + resolverAddress: resolver?.address, + }; + + await executeRap( + wallet, + RapActionTypes.setRecordsENS, + setRecordsEnsRegistrationParameters, + callback + ); + }, + [ + accountAddress, + avatarMetadata, + coverMetadata, + getNextNonce, + registrationParameters, + ] + ); + + const actions = useMemo( + () => ({ + [REGISTRATION_STEPS.COMMIT]: commitAction, + [REGISTRATION_STEPS.EDIT]: setRecordsAction, + [REGISTRATION_STEPS.REGISTER]: registerAction, + [REGISTRATION_STEPS.RENEW]: renewAction, + [REGISTRATION_STEPS.SET_NAME]: setNameAction, + [REGISTRATION_STEPS.WAIT_COMMIT_CONFIRMATION]: speedUpCommitAction, + [REGISTRATION_STEPS.WAIT_ENS_COMMITMENT]: () => null, + }), + [ + commitAction, + registerAction, + renewAction, + setNameAction, + setRecordsAction, + speedUpCommitAction, + ] + ); + + return { + action: actions[registrationStep] as (...args: any) => void, + }; +} + +async function uploadRecordImages( + records: Partial | undefined, + imageMetadata: { avatar?: Image; cover?: Image } +) { + const uploadRecordImage = async (key: 'avatar' | 'cover') => { + if ( + (records?.[key]?.startsWith('~') || records?.[key]?.startsWith('file')) && + imageMetadata[key] + ) { + try { + const { url } = await uploadImage({ + filename: imageMetadata[key]?.filename || '', + mime: imageMetadata[key]?.mime || '', + path: imageMetadata[key]?.path || '', + }); + return url; + } catch (error) { + logger.sentry('[uploadRecordImages] Failed to upload image.', error); + return undefined; + } + } + return records?.[key]; + }; + + const [avatar, cover] = await Promise.all([ + uploadRecordImage('avatar'), + uploadRecordImage('cover'), + ]); + + return { + ...records, + avatar, + cover, + }; +} diff --git a/src/hooks/useENSRegistrationCosts.ts b/src/hooks/useENSRegistrationCosts.ts index d047c0994d7..d22e74ed491 100644 --- a/src/hooks/useENSRegistrationCosts.ts +++ b/src/hooks/useENSRegistrationCosts.ts @@ -1,69 +1,403 @@ -import { useCallback, useMemo } from 'react'; -import { useQuery } from 'react-query'; +import { BigNumberish } from 'ethers'; +import { isEmpty } from 'lodash'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useQueries } from 'react-query'; +import useENSRegistration from './useENSRegistration'; +import useGas from './useGas'; +import usePrevious from './usePrevious'; import { useAccountSettings } from '.'; -import { estimateENSRegistrationGasLimit } from '@rainbow-me/handlers/ens'; import { + estimateENSCommitGasLimit, + estimateENSRegisterSetRecordsAndNameGasLimit, + estimateENSRenewGasLimit, + estimateENSSetNameGasLimit, + estimateENSSetRecordsGasLimit, + fetchReverseRecord, +} from '@rainbow-me/handlers/ens'; +import { NetworkTypes } from '@rainbow-me/helpers'; +import { + ENS_DOMAIN, formatEstimatedNetworkFee, formatRentPrice, formatTotalRegistrationCost, + generateSalt, + getRentPrice, + REGISTRATION_MODES, + REGISTRATION_STEPS, } from '@rainbow-me/helpers/ens'; import { Network } from '@rainbow-me/helpers/networkTypes'; -import { add, addDisplay, multiply } from '@rainbow-me/helpers/utilities'; -import { getEIP1559GasParams } from '@rainbow-me/redux/gas'; -import { timeUnits } from '@rainbow-me/references'; -import { ethereumUtils } from '@rainbow-me/utils'; +import { + add, + addBuffer, + addDisplay, + fromWei, + greaterThanOrEqualTo, + multiply, +} from '@rainbow-me/helpers/utilities'; +import { ethUnits, timeUnits } from '@rainbow-me/references'; +import { ethereumUtils, gasUtils } from '@rainbow-me/utils'; + +enum QUERY_KEYS { + GET_COMMIT_GAS_LIMIT = 'GET_COMMIT_GAS_LIMIT', + GET_RENEW_GAS_LIMIT = 'GET_RENEW_GAS_LIMIT', + GET_REVERSE_RECORD = 'GET_REVERSE_RECORD', + GET_REGISTER_RAP_GAS_LIMIT = 'GET_REGISTER_RAP_GAS_LIMIT', + GET_SET_NAME_GAS_LIMIT = 'GET_SET_NAME_GAS_LIMIT', + GET_SET_RECORDS_GAS_LIMIT = 'GET_SET_RECORDS_GAS_LIMIT', +} + +const QUERY_STALE_TIME = 15000; +const { NORMAL } = gasUtils; export default function useENSRegistrationCosts({ - duration, - name, + name: inputName, rentPrice, + sendReverseRecord, + step, + yearsDuration, }: { - duration: number; name: string; - rentPrice?: { wei: number; perYear: { wei: number } }; + rentPrice?: { wei: BigNumberish; perYear: { wei: string } }; + sendReverseRecord?: boolean; + step: keyof typeof REGISTRATION_STEPS; + yearsDuration: number; }) { const { nativeCurrency, accountAddress } = useAccountSettings(); + const { registrationParameters, mode } = useENSRegistration(); + const duration = yearsDuration * timeUnits.secs.year; + const name = inputName.replace(ENS_DOMAIN, ''); + const { + gasFeeParamsBySpeed: useGasGasFeeParamsBySpeed, + currentBlockParams: useGasCurrentBlockParams, + updateTxFee, + startPollingGasFees, + isSufficientGas: useGasIsSufficientGas, + isValidGas: useGasIsValidGas, + gasLimit: useGasGasLimit, + selectedGasFeeOption, + } = useGas(); - const rentPriceInWei = rentPrice?.wei?.toString(); + const [gasFeeParams, setGasFeeParams] = useState({ + currentBaseFee: useGasCurrentBlockParams?.baseFeePerGas, + gasFeeParamsBySpeed: useGasGasFeeParamsBySpeed, + }); - const getEstimatedNetworkFee = useCallback(async () => { - if (!rentPriceInWei) return; + const nameUpdated = useMemo(() => { + return registrationParameters?.name !== name && name?.length > 2; + }, [name, registrationParameters?.name]); - const nativeAssetPrice = ethereumUtils.getPriceOfNativeAssetForNetwork( - Network.mainnet + const changedRecords = useMemo( + () => registrationParameters?.changedRecords || {}, + [registrationParameters?.changedRecords] + ); + const [currentStepGasLimit, setCurrentStepGasLimit] = useState(''); + + const [isValidGas, setIsValidGas] = useState(false); + const [isSufficientGas, setIsSufficientGas] = useState(false); + + const prevIsSufficientGas = usePrevious(isSufficientGas); + const prevIsValidGas = usePrevious(isValidGas); + + const rentPriceInWei = rentPrice?.wei?.toString(); + + const checkIfSufficientEth = useCallback((wei: string) => { + const nativeAsset = ethereumUtils.getNetworkNativeAsset( + NetworkTypes.mainnet ); + const balanceAmount = nativeAsset?.balance?.amount || 0; + const txFeeAmount = fromWei(wei); + const isSufficientGas = greaterThanOrEqualTo(balanceAmount, txFeeAmount); + return isSufficientGas; + }, []); - const { totalRegistrationGasLimit } = await estimateENSRegistrationGasLimit( + const getCommitGasLimit = useCallback(async () => { + const salt = generateSalt(); + const newCommitGasLimit = await estimateENSCommitGasLimit({ + duration, name, - accountAddress, - duration * timeUnits.secs.year, - rentPriceInWei + ownerAddress: accountAddress, + rentPrice: rentPriceInWei as string, + salt, + }); + return newCommitGasLimit || ''; + }, [accountAddress, duration, name, rentPriceInWei]); + + const getRegisterRapGasLimit = useCallback(async () => { + const newRegisterRapGasLimit = await estimateENSRegisterSetRecordsAndNameGasLimit( + { + duration, + name, + ownerAddress: accountAddress, + records: changedRecords, + rentPrice: registrationParameters?.rentPrice, + salt: registrationParameters?.salt, + setReverseRecord: sendReverseRecord, + } ); + return newRegisterRapGasLimit || ''; + }, [ + accountAddress, + duration, + name, + registrationParameters?.rentPrice, + registrationParameters?.salt, + sendReverseRecord, + changedRecords, + ]); + + const getSetRecordsGasLimit = useCallback(async () => { + const newSetRecordsGasLimit = await estimateENSSetRecordsGasLimit({ + name: `${name}${ENS_DOMAIN}`, + ownerAddress: + mode === REGISTRATION_MODES.EDIT ? accountAddress : undefined, + records: changedRecords, + }); + return newSetRecordsGasLimit || ''; + }, [changedRecords, name, accountAddress, mode]); + + const getSetNameGasLimit = useCallback(async () => { + const newSetNameGasLimit = await estimateENSSetNameGasLimit({ + name: `${name}${ENS_DOMAIN}`, + ownerAddress: accountAddress, + }); + return newSetNameGasLimit || ''; + }, [accountAddress, name]); + + const getRenewGasLimit = useCallback(async () => { + const cleanName = registrationParameters?.name?.replace(ENS_DOMAIN, ''); + const rentPrice = await getRentPrice(cleanName, duration); + const newRenewGasLimit = await estimateENSRenewGasLimit({ + duration, + name: cleanName, + rentPrice: rentPrice?.toString(), + }); + return newRenewGasLimit || ''; + }, [registrationParameters?.name, duration]); + + const getReverseRecord = useCallback(async () => { + const reverseRecord = await fetchReverseRecord(accountAddress); + return Boolean(reverseRecord); + }, [accountAddress]); - const { gasFeeParamsBySpeed, currentBaseFee } = await getEIP1559GasParams(); + const queries = useQueries([ + { + enabled: step === REGISTRATION_STEPS.COMMIT && nameUpdated, + queryFn: getCommitGasLimit, + queryKey: [QUERY_KEYS.GET_COMMIT_GAS_LIMIT, name], + staleTime: QUERY_STALE_TIME, + }, + { + enabled: + (step === REGISTRATION_STEPS.COMMIT || + step === REGISTRATION_STEPS.SET_NAME) && + nameUpdated, + queryFn: getSetNameGasLimit, + queryKey: [QUERY_KEYS.GET_SET_NAME_GAS_LIMIT, name], + staleTime: QUERY_STALE_TIME, + }, + { + enabled: + step === REGISTRATION_STEPS.COMMIT || step === REGISTRATION_STEPS.EDIT, + queryFn: getSetRecordsGasLimit, + queryKey: [QUERY_KEYS.GET_SET_RECORDS_GAS_LIMIT, name, changedRecords], + staleTime: QUERY_STALE_TIME, + }, + { + enabled: + (step === REGISTRATION_STEPS.COMMIT || + step === REGISTRATION_STEPS.REGISTER) && + nameUpdated, + queryFn: getReverseRecord, + queryKey: [QUERY_KEYS.GET_REVERSE_RECORD, name], + staleTime: QUERY_STALE_TIME, + }, + { + enabled: step === REGISTRATION_STEPS.RENEW, + queryFn: getRenewGasLimit, + queryKey: [ + QUERY_KEYS.GET_RENEW_GAS_LIMIT, + registrationParameters?.name, + duration, + ], + staleTime: QUERY_STALE_TIME, + }, + { + enabled: step === REGISTRATION_STEPS.REGISTER, + queryFn: getRegisterRapGasLimit, + queryKey: [ + QUERY_KEYS.GET_REGISTER_RAP_GAS_LIMIT, + sendReverseRecord, + nameUpdated, + changedRecords, + ], + staleTime: QUERY_STALE_TIME, + }, + ]); + + const queriesByKey = useMemo( + () => ({ + GET_COMMIT_GAS_LIMIT: queries[0], + GET_REGISTER_RAP_GAS_LIMIT: queries[5], + GET_RENEW_GAS_LIMIT: queries[4], + GET_REVERSE_RECORD: queries[3], + GET_SET_NAME_GAS_LIMIT: queries[1], + GET_SET_RECORDS_GAS_LIMIT: queries[2], + }), + [queries] + ); + + const commitGasLimit = useMemo( + () => queriesByKey.GET_COMMIT_GAS_LIMIT.data || '', + [queriesByKey] + ); + const renewGasLimit = useMemo( + () => queriesByKey.GET_RENEW_GAS_LIMIT.data || '', + [queriesByKey] + ); + const setRecordsGasLimit = useMemo( + () => queriesByKey.GET_SET_RECORDS_GAS_LIMIT.data || '', + [queriesByKey.GET_SET_RECORDS_GAS_LIMIT] + ); + const registerRapGasLimit = useMemo( + () => queriesByKey.GET_REGISTER_RAP_GAS_LIMIT.data || '', + [queriesByKey.GET_REGISTER_RAP_GAS_LIMIT] + ); + const setNameGasLimit = useMemo( + () => queriesByKey.GET_SET_NAME_GAS_LIMIT.data || '', + [queriesByKey.GET_SET_NAME_GAS_LIMIT] + ); + const hasReverseRecord = useMemo( + () => queriesByKey.GET_REVERSE_RECORD.data || false, + [queriesByKey.GET_REVERSE_RECORD] + ); + + const stepGasLimit = useMemo( + () => ({ + [REGISTRATION_STEPS.COMMIT]: commitGasLimit, + [REGISTRATION_STEPS.RENEW]: renewGasLimit, + [REGISTRATION_STEPS.EDIT]: setRecordsGasLimit, + [REGISTRATION_STEPS.REGISTER]: registerRapGasLimit, + [REGISTRATION_STEPS.SET_NAME]: setNameGasLimit, + [REGISTRATION_STEPS.WAIT_COMMIT_CONFIRMATION]: null, + [REGISTRATION_STEPS.WAIT_ENS_COMMITMENT]: null, + }), + [ + commitGasLimit, + registerRapGasLimit, + renewGasLimit, + setNameGasLimit, + setRecordsGasLimit, + ] + ); + + const estimatedFee = useMemo(() => { + const nativeAssetPrice = ethereumUtils.getPriceOfNativeAssetForNetwork( + Network.mainnet + ); + const { gasFeeParamsBySpeed, currentBaseFee } = gasFeeParams; + + let estimatedGasLimit = ''; + if (step === REGISTRATION_STEPS.COMMIT) { + estimatedGasLimit = [ + commitGasLimit, + setRecordsGasLimit, + // gas limit estimat for registerWithConfig fails if there's no commit tx sent first + `${ethUnits.ens_register_with_config}`, + !hasReverseRecord ? setNameGasLimit : '', + ].reduce((a, b) => add(a || 0, b || 0)); + } else if (step === REGISTRATION_STEPS.RENEW) { + estimatedGasLimit = renewGasLimit; + } else if (step === REGISTRATION_STEPS.SET_NAME) { + estimatedGasLimit = setNameGasLimit; + } else if (step === REGISTRATION_STEPS.EDIT) { + estimatedGasLimit = setRecordsGasLimit; + } else if (step === REGISTRATION_STEPS.REGISTER) { + estimatedGasLimit = registerRapGasLimit; + } const formattedEstimatedNetworkFee = formatEstimatedNetworkFee( - totalRegistrationGasLimit, - currentBaseFee.gwei, - gasFeeParamsBySpeed.normal.maxPriorityFeePerGas.gwei, + estimatedGasLimit, + currentBaseFee?.gwei, + gasFeeParamsBySpeed?.[selectedGasFeeOption || NORMAL] + ?.maxPriorityFeePerGas?.gwei, nativeCurrency, nativeAssetPrice ); return { - estimatedGasLimit: totalRegistrationGasLimit, + estimatedGasLimit, estimatedNetworkFee: formattedEstimatedNetworkFee, }; - }, [accountAddress, duration, name, nativeCurrency, rentPriceInWei]); + }, [ + gasFeeParams, + step, + nativeCurrency, + commitGasLimit, + setRecordsGasLimit, + hasReverseRecord, + setNameGasLimit, + renewGasLimit, + registerRapGasLimit, + selectedGasFeeOption, + ]); - const { data: estimatedFee, status, isIdle, isLoading } = useQuery( - [ - 'getEstimatedNetworkFee', - [accountAddress, name, nativeCurrency, rentPriceInWei], - ], - getEstimatedNetworkFee, - { cacheTime: 0, enabled: Boolean(rentPriceInWei) } - ); + useEffect(() => { + if ( + useGasIsSufficientGas !== null && + useGasIsSufficientGas !== prevIsSufficientGas + ) { + setIsSufficientGas(useGasIsSufficientGas); + } + }, [prevIsSufficientGas, setIsSufficientGas, useGasIsSufficientGas]); + + useEffect(() => { + if (useGasIsValidGas !== null && useGasIsValidGas !== prevIsValidGas) { + setIsValidGas(useGasIsValidGas); + } + }, [prevIsSufficientGas, prevIsValidGas, setIsValidGas, useGasIsValidGas]); + + useEffect(() => { + if (!currentStepGasLimit) startPollingGasFees(); + }, [currentStepGasLimit, startPollingGasFees, step]); + + useEffect(() => { + if ( + !isEmpty(useGasGasFeeParamsBySpeed) && + gasFeeParams.gasFeeParamsBySpeed !== useGasGasFeeParamsBySpeed && + gasFeeParams.currentBaseFee !== useGasCurrentBlockParams.baseFeePerGas && + useGasCurrentBlockParams.baseFeePerGas + ) { + setGasFeeParams({ + currentBaseFee: useGasCurrentBlockParams.baseFeePerGas, + gasFeeParamsBySpeed: useGasGasFeeParamsBySpeed, + }); + } + }, [ + gasFeeParams.currentBaseFee, + gasFeeParams.gasFeeParamsBySpeed, + setGasFeeParams, + useGasCurrentBlockParams, + useGasGasFeeParamsBySpeed, + ]); + + useEffect(() => { + if ( + stepGasLimit[step] && + (!useGasGasLimit || currentStepGasLimit !== stepGasLimit[step]) && + !isEmpty(useGasGasFeeParamsBySpeed) + ) { + updateTxFee(stepGasLimit?.[step], null); + setCurrentStepGasLimit(stepGasLimit?.[step] || ''); + } + }, [ + currentStepGasLimit, + step, + stepGasLimit, + updateTxFee, + setCurrentStepGasLimit, + useGasGasFeeParamsBySpeed, + useGasGasLimit, + ]); const data = useMemo(() => { const rentPricePerYearInWei = rentPrice?.perYear?.wei?.toString(); @@ -72,15 +406,17 @@ export default function useENSRegistrationCosts({ ); if (rentPricePerYearInWei) { - const rentPriceInWei = multiply(rentPricePerYearInWei, duration); + const rentPriceInWei = multiply(rentPricePerYearInWei, yearsDuration); const estimatedRentPrice = formatRentPrice( rentPriceInWei, duration, nativeCurrency, nativeAssetPrice ); - - if (estimatedFee) { + if ( + estimatedFee?.estimatedGasLimit && + estimatedFee?.estimatedNetworkFee?.amount + ) { const weiEstimatedTotalCost = add( estimatedFee.estimatedNetworkFee.wei, estimatedRentPrice.wei.toString() @@ -95,6 +431,16 @@ export default function useENSRegistrationCosts({ nativeAssetPrice ); + const isSufficientGasForRegistration = checkIfSufficientEth( + addBuffer( + add( + estimatedFee?.estimatedNetworkFee?.wei, + estimatedRentPrice?.wei?.toString() + ), + 1.1 + ) + ); + return { estimatedGasLimit: estimatedFee.estimatedGasLimit, estimatedNetworkFee: estimatedFee.estimatedNetworkFee, @@ -103,14 +449,55 @@ export default function useENSRegistrationCosts({ ...estimatedTotalRegistrationCost, display: displayEstimatedTotalCost, }, + gasFeeParamsBySpeed: useGasGasFeeParamsBySpeed, + hasReverseRecord, + isSufficientGas, + isSufficientGasForRegistration, + isSufficientGasForStep: + stepGasLimit[step] && isValidGas && isSufficientGas, + isValidGas, + stepGasLimit: stepGasLimit[step], }; } return { estimatedRentPrice }; } - }, [duration, estimatedFee, nativeCurrency, rentPrice?.perYear?.wei]); + }, [ + checkIfSufficientEth, + duration, + estimatedFee, + hasReverseRecord, + isSufficientGas, + isValidGas, + nativeCurrency, + rentPrice?.perYear?.wei, + step, + stepGasLimit, + useGasGasFeeParamsBySpeed, + yearsDuration, + ]); + + const gasFeeReady = useMemo( + () => + !isEmpty(useGasGasFeeParamsBySpeed) && !isEmpty(useGasCurrentBlockParams), + [useGasCurrentBlockParams, useGasGasFeeParamsBySpeed] + ); - const isSuccess = status === 'success' && !!data?.estimatedRentPrice; + const { isSuccess, isLoading, isIdle } = useMemo(() => { + const statusQueries = queries.slice(0, 2); + const isSuccess = + !statusQueries.some(a => a.status !== 'success') && + !!data?.estimatedRentPrice && + gasFeeReady; + const isLoading = + statusQueries + .map(({ isLoading }) => isLoading) + .reduce((a, b) => a || b) && !gasFeeReady; + const isIdle = statusQueries + .map(({ isIdle }) => ({ isIdle })) + .reduce((a, b) => a && b); + return { isIdle, isLoading, isSuccess }; + }, [data?.estimatedRentPrice, gasFeeReady, queries]); return { data, diff --git a/src/hooks/useENSRegistrationForm.ts b/src/hooks/useENSRegistrationForm.ts new file mode 100644 index 00000000000..8b4648e9bd5 --- /dev/null +++ b/src/hooks/useENSRegistrationForm.ts @@ -0,0 +1,301 @@ +import { isEmpty, omit } from 'lodash'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { atom, useRecoilState } from 'recoil'; +import { useENSModifiedRegistration, useENSRegistration } from '.'; +import { Records } from '@rainbow-me/entities'; +import { + ENS_RECORDS, + REGISTRATION_MODES, + TextRecordField, + textRecordFields, +} from '@rainbow-me/helpers/ens'; + +const disabledAtom = atom({ + default: false, + key: 'ensProfileForm.disabled', +}); + +const errorsAtom = atom<{ [name: string]: string }>({ + default: {}, + key: 'ensProfileForm.errors', +}); + +const selectedFieldsAtom = atom({ + default: [], + key: 'ensProfileForm.selectedFields', +}); + +const submittingAtom = atom({ + default: false, + key: 'ensProfileForm.submitting', +}); + +export const valuesAtom = atom<{ [name: string]: Partial }>({ + default: {}, + key: 'ensProfileForm.values', +}); + +const defaultInitialRecords = { + [ENS_RECORDS.displayName]: '', + [ENS_RECORDS.description]: '', + [ENS_RECORDS.url]: '', + [ENS_RECORDS.twitter]: '', +}; + +const cleanFormRecords = (initialRecords: Records) => { + // delete these to show an empty form if the user only have one of these set + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { ETH, avatar, cover, ...cleanFormRecords } = initialRecords; + // if ENS has some records, only show those + if (Object.keys(cleanFormRecords).length) return initialRecords; + return { ...defaultInitialRecords, ...initialRecords }; +}; + +export default function useENSRegistrationForm({ + defaultFields, + initializeForm, +}: { + defaultFields?: TextRecordField[]; + /** A flag that indicates if a new form should be initialised */ + initializeForm?: boolean; +} = {}) { + const { + name, + mode, + initialRecords, + records: allRecords, + removeRecordByKey, + updateRecordByKey, + updateRecords, + } = useENSRegistration(); + const { changedRecords, profileQuery } = useENSModifiedRegistration(); + + // The initial records will be the existing records belonging to the profile in "edit mode", + // but will be all of the records in "create mode". + const defaultRecords = useMemo(() => { + return mode === REGISTRATION_MODES.EDIT + ? cleanFormRecords(initialRecords) + : allRecords; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialRecords, mode]); + + const [errors, setErrors] = useRecoilState(errorsAtom); + const [submitting, setSubmitting] = useRecoilState(submittingAtom); + + const [disabled, setDisabled] = useRecoilState(disabledAtom); + useEffect(() => { + // If we are in edit mode, we want to disable the "Review" button + // when there are no changed records. + // Note: We don't want to do this in create mode as we have the "Skip" + // button. + setDisabled( + mode === REGISTRATION_MODES.EDIT ? isEmpty(changedRecords) : false + ); + }, [changedRecords, disabled, mode, setDisabled]); + + const [selectedFields, setSelectedFields] = useRecoilState( + selectedFieldsAtom + ); + useEffect(() => { + if (!initializeForm) return; + // If there are existing records in the global state, then we + // populate with that. + if (!isEmpty(defaultRecords)) { + setSelectedFields( + // @ts-ignore + Object.keys(defaultRecords) + // @ts-ignore + .map(key => textRecordFields[key]) + .filter(Boolean) + ); + } else { + if (defaultFields) { + setSelectedFields(defaultFields); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [name, defaultRecords]); + + const [valuesMap, setValuesMap] = useRecoilState(valuesAtom); + const values = useMemo(() => valuesMap[name] || {}, [name, valuesMap]); + useEffect( + () => { + if (!initializeForm) return; + setValuesMap(values => ({ + ...values, + [name]: defaultRecords, + })); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [name, defaultRecords] + ); + + // Set initial records in redux depending on user input (defaultFields) + useEffect(() => { + if (!initializeForm) return; + if (defaultFields && isEmpty(defaultRecords)) { + const records = defaultFields.reduce((records, field) => { + return { + ...records, + [field.key]: '', + }; + }, {}); + updateRecords(records); + } else if (mode === REGISTRATION_MODES.EDIT && !isEmpty(defaultRecords)) { + updateRecords(defaultRecords); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEmpty(defaultRecords), updateRecords]); + + const onAddField = useCallback( + (fieldToAdd, selectedFields) => { + setSelectedFields(selectedFields); + updateRecordByKey(fieldToAdd.key, ''); + }, + [setSelectedFields, updateRecordByKey] + ); + + const onRemoveField = useCallback( + (fieldToRemove, selectedFields = undefined) => { + if (!isEmpty(errors)) { + setErrors(errors => { + const newErrors = omit(errors, fieldToRemove.key); + return newErrors; + }); + } + if (selectedFields) { + setSelectedFields(selectedFields); + } + removeRecordByKey(fieldToRemove.key); + setValuesMap(values => ({ + ...values, + [name]: omit(values?.[name] || {}, fieldToRemove.key) as Records, + })); + }, + [ + errors, + name, + removeRecordByKey, + setErrors, + setSelectedFields, + setValuesMap, + ] + ); + + const onBlurField = useCallback( + ({ key, value }) => { + setValuesMap(values => ({ + ...values, + [name]: { ...values?.[name], [key]: value }, + })); + updateRecordByKey(key, value); + }, + [name, setValuesMap, updateRecordByKey] + ); + + const onChangeField = useCallback( + ({ key, value }) => { + if (!isEmpty(errors)) { + setErrors(errors => { + const newErrors = omit(errors, key); + return newErrors; + }); + } + + setValuesMap(values => ({ + ...values, + [name]: { ...values?.[name], [key]: value }, + })); + updateRecordByKey(key, value); + }, + [errors, name, setErrors, setValuesMap, updateRecordByKey] + ); + + const blurFields = useCallback(() => { + updateRecords(values); + }, [updateRecords, values]); + + const [isLoading, setIsLoading] = useState( + mode === REGISTRATION_MODES.EDIT && + (!profileQuery.isSuccess || isEmpty(values)) + ); + + useEffect(() => { + if (mode === REGISTRATION_MODES.EDIT) { + if (profileQuery.isSuccess || !isEmpty(values)) { + setTimeout(() => setIsLoading(false), 200); + } else { + setIsLoading(true); + } + } + }, [mode, profileQuery.isSuccess, values]); + + const clearValues = useCallback(() => { + setValuesMap({}); + }, [setValuesMap]); + + const empty = useMemo(() => !Object.values(values).some(Boolean), [values]); + + const submit = useCallback( + async submitFn => { + const errors = Object.entries(textRecordFields).reduce( + (currentErrors, [key, { validations }]) => { + const value = values[key as ENS_RECORDS]; + if (validations?.onSubmit?.match) { + const { value: regex, message } = + validations?.onSubmit?.match || {}; + if (regex && value && !value.match(regex)) { + return { + ...currentErrors, + [key]: message, + }; + } + } + if (validations?.onSubmit?.validate) { + const { callback, message } = validations?.onSubmit?.validate || {}; + if (value && !callback(value)) { + return { + ...currentErrors, + [key]: message, + }; + } + } + return currentErrors; + }, + {} + ); + setErrors(errors); + + setSubmitting(true); + if (isEmpty(errors)) { + try { + await submitFn(); + // eslint-disable-next-line no-empty + } catch (err) {} + } + setTimeout(() => { + setSubmitting(false); + }, 100); + }, + [setErrors, setSubmitting, values] + ); + + return { + blurFields, + clearValues, + disabled, + errors, + isEmpty: empty, + isLoading, + onAddField, + onBlurField, + onChangeField, + onRemoveField, + profileQuery, + selectedFields, + setDisabled, + submit, + submitting, + values, + }; +} diff --git a/src/hooks/useENSRegistrationStepHandler.tsx b/src/hooks/useENSRegistrationStepHandler.tsx new file mode 100644 index 00000000000..8a61abe6f7c --- /dev/null +++ b/src/hooks/useENSRegistrationStepHandler.tsx @@ -0,0 +1,196 @@ +import { differenceInSeconds } from 'date-fns'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + // @ts-ignore + IS_TESTING, +} from 'react-native-dotenv'; +import { useDispatch } from 'react-redux'; +import usePrevious from './usePrevious'; +import { useENSRegistration } from '.'; +import { + getProviderForNetwork, + isHardHat, + web3Provider, +} from '@rainbow-me/handlers/web3'; +import { + ENS_SECONDS_WAIT, + REGISTRATION_MODES, + REGISTRATION_STEPS, +} from '@rainbow-me/helpers/ens'; +import { updateTransactionRegistrationParameters } from '@rainbow-me/redux/ensRegistration'; + +const getBlockMsTimestamp = (block: { timestamp: number }) => + block.timestamp * 1000; + +export default function useENSRegistrationStepHandler(observer = true) { + const dispatch = useDispatch(); + const { registrationParameters, mode } = useENSRegistration(); + const commitTransactionHash = registrationParameters?.commitTransactionHash; + const prevCommitTrasactionHash = usePrevious(commitTransactionHash); + + const timeout = useRef(); + + const [ + secondsSinceCommitConfirmed, + setSecondsSinceCommitConfirmed, + ] = useState( + (registrationParameters?.commitTransactionConfirmedAt && + differenceInSeconds( + Date.now(), + registrationParameters?.commitTransactionConfirmedAt + )) || + -1 + ); + + const isTestingHardhat = useMemo( + () => IS_TESTING === 'true' && isHardHat(web3Provider.connection.url), + [] + ); + + const [readyToRegister, setReadyToRegister] = useState( + isTestingHardhat || secondsSinceCommitConfirmed > 60 + ); + + // flag to wait 10 secs before we get the tx block, to be able to simulate not confirmed tx when testing + const shouldLoopForConfirmation = useRef(isTestingHardhat); + + const registrationStep = useMemo(() => { + if (mode === REGISTRATION_MODES.EDIT) return REGISTRATION_STEPS.EDIT; + if (mode === REGISTRATION_MODES.RENEW) return REGISTRATION_STEPS.RENEW; + if (mode === REGISTRATION_MODES.SET_NAME) + return REGISTRATION_STEPS.SET_NAME; + // still waiting for the COMMIT tx to be sent + if (!registrationParameters.commitTransactionHash) + return REGISTRATION_STEPS.COMMIT; + // COMMIT tx sent, but not confirmed yet + if (!registrationParameters.commitTransactionConfirmedAt) + return REGISTRATION_STEPS.WAIT_COMMIT_CONFIRMATION; + // COMMIT tx was confirmed but 60 secs haven't passed yet + // or current block is not 60 secs ahead of COMMIT tx block + if (secondsSinceCommitConfirmed < ENS_SECONDS_WAIT || !readyToRegister) + return REGISTRATION_STEPS.WAIT_ENS_COMMITMENT; + return REGISTRATION_STEPS.REGISTER; + }, [ + registrationParameters.commitTransactionHash, + registrationParameters.commitTransactionConfirmedAt, + mode, + secondsSinceCommitConfirmed, + readyToRegister, + ]); + + const watchCommitTransaction = useCallback(async () => { + if (observer) return; + const provider = await getProviderForNetwork(); + let confirmed = false; + const tx = await provider.getTransaction(commitTransactionHash || ''); + if (!tx?.blockHash) return confirmed; + const block = await provider.getBlock(tx.blockHash || ''); + if (!shouldLoopForConfirmation.current && block?.timestamp) { + const now = Date.now(); + const msBlockTimestamp = getBlockMsTimestamp(block); + // hardhat block timestamp is behind + const timeDifference = isTestingHardhat ? now - msBlockTimestamp : 0; + const commitTransactionConfirmedAt = msBlockTimestamp + timeDifference; + const secs = differenceInSeconds(now, commitTransactionConfirmedAt); + setSecondsSinceCommitConfirmed(secs); + dispatch( + updateTransactionRegistrationParameters({ + commitTransactionConfirmedAt, + }) + ); + confirmed = true; + } else if (shouldLoopForConfirmation.current) { + shouldLoopForConfirmation.current = false; + } + return confirmed; + }, [observer, dispatch, isTestingHardhat, commitTransactionHash]); + + const startPollingWatchCommitTransaction = useCallback(async () => { + if (observer) return; + timeout.current && clearTimeout(timeout.current); + if (registrationStep !== REGISTRATION_STEPS.WAIT_COMMIT_CONFIRMATION) + return; + const confirmed = await watchCommitTransaction(); + if (!confirmed) { + timeout.current = setTimeout(() => { + startPollingWatchCommitTransaction(); + }, 2000); + } + }, [observer, registrationStep, watchCommitTransaction]); + + useEffect(() => { + // we need to update the loop with new commit transaction hash in case of speed ups + if ( + !observer && + !!prevCommitTrasactionHash && + !!commitTransactionHash && + prevCommitTrasactionHash !== commitTransactionHash + ) { + timeout.current && clearTimeout(timeout.current); + startPollingWatchCommitTransaction(); + } + }, [ + observer, + commitTransactionHash, + prevCommitTrasactionHash, + startPollingWatchCommitTransaction, + ]); + + useEffect(() => { + if (observer) return; + if (registrationStep === REGISTRATION_STEPS.WAIT_COMMIT_CONFIRMATION) { + startPollingWatchCommitTransaction(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [observer, registrationStep]); + + useEffect(() => { + if (observer) return; + let interval: NodeJS.Timer; + if (registrationStep === REGISTRATION_STEPS.WAIT_ENS_COMMITMENT) { + interval = setInterval(() => { + setSecondsSinceCommitConfirmed(seconds => seconds + 1); + }, 1000); + } + return () => clearInterval(interval); + }, [observer, registrationStep, secondsSinceCommitConfirmed]); + + useEffect(() => { + if (observer) return; + // we need to check from blocks if the time has passed or not + const checkRegisterBlockTimestamp = async () => { + try { + const provider = await getProviderForNetwork(); + const block = await provider.getBlock('latest'); + const msBlockTimestamp = getBlockMsTimestamp(block); + const secs = differenceInSeconds( + msBlockTimestamp, + registrationParameters?.commitTransactionConfirmedAt || + msBlockTimestamp + ); + if (secs > ENS_SECONDS_WAIT) setReadyToRegister(true); + // eslint-disable-next-line no-empty + } catch (e) {} + }; + if (secondsSinceCommitConfirmed >= ENS_SECONDS_WAIT) { + checkRegisterBlockTimestamp(); + } + }, [ + isTestingHardhat, + observer, + registrationParameters?.commitTransactionConfirmedAt, + registrationStep, + secondsSinceCommitConfirmed, + ]); + + useEffect( + () => () => { + !observer && timeout.current && clearTimeout(timeout.current); + }, + [observer] + ); + return { + secondsSinceCommitConfirmed, + step: registrationStep, + }; +} diff --git a/src/hooks/useENSResolveName.ts b/src/hooks/useENSResolveName.ts new file mode 100644 index 00000000000..cc39d92e524 --- /dev/null +++ b/src/hooks/useENSResolveName.ts @@ -0,0 +1,17 @@ +import { useQuery } from 'react-query'; +import { + getResolveName, + saveResolveName, +} from '@rainbow-me/handlers/localstorage/ens'; +import { web3Provider } from '@rainbow-me/handlers/web3'; + +export default function useENSResolveName(ensName: string) { + return useQuery(['resolve-name', ensName], async () => { + const cachedAddress = await getResolveName(ensName); + if (cachedAddress) return cachedAddress; + + const address = await web3Provider.resolveName(ensName); + address && saveResolveName(ensName, address); + return address; + }); +} diff --git a/src/hooks/useENSSearch.ts b/src/hooks/useENSSearch.ts new file mode 100644 index 00000000000..f0ce5984450 --- /dev/null +++ b/src/hooks/useENSSearch.ts @@ -0,0 +1,98 @@ +import { format } from 'date-fns'; +import { useCallback, useMemo } from 'react'; +import { useQuery } from 'react-query'; +import { useAccountSettings } from '.'; +import { fetchRegistrationDate } from '@rainbow-me/handlers/ens'; +import { + ENS_DOMAIN, + formatRentPrice, + getAvailable, + getNameExpires, + getRentPrice, +} from '@rainbow-me/helpers/ens'; +import { Network } from '@rainbow-me/helpers/networkTypes'; +import { timeUnits } from '@rainbow-me/references'; +import { ethereumUtils, validateENS } from '@rainbow-me/utils'; + +const formatTime = (timestamp: string, abbreviated: boolean = true) => { + const style = abbreviated ? 'MMM d, y' : 'MMMM d, y'; + return format(new Date(Number(timestamp) * 1000), style); +}; + +export default function useENSSearch({ + yearsDuration = 1, + name: inputName, +}: { + yearsDuration?: number; + name: string; +}) { + const name = inputName.replace(ENS_DOMAIN, ''); + const { nativeCurrency } = useAccountSettings(); + const isValidLength = useMemo(() => name.length > 2, [name.length]); + const duration = yearsDuration * timeUnits.secs.year; + const getRegistrationValues = useCallback(async () => { + const ensValidation = validateENS(`${name}${ENS_DOMAIN}`, { + includeSubdomains: false, + }); + + if (!ensValidation.valid) { + return { + code: ensValidation.code, + hint: ensValidation.hint, + valid: false, + }; + } + + const isAvailable = await getAvailable(name); + const rentPrice = await getRentPrice(name, duration); + const nativeAssetPrice = ethereumUtils.getPriceOfNativeAssetForNetwork( + Network.mainnet + ); + const formattedRentPrice = formatRentPrice( + rentPrice, + yearsDuration, + nativeCurrency, + nativeAssetPrice + ); + if (isAvailable) { + return { + available: isAvailable, + rentPrice: formattedRentPrice, + valid: true, + }; + } else { + // we need the expiration and registration date when is not available + const registrationDate = await fetchRegistrationDate(name + ENS_DOMAIN); + const nameExpires = await getNameExpires(name); + const formattedRegistrarionDate = formatTime(registrationDate, false); + const formattedExpirationDate = formatTime(nameExpires); + + return { + available: isAvailable, + expirationDate: formattedExpirationDate, + registrationDate: formattedRegistrarionDate, + rentPrice: formattedRentPrice, + valid: true, + }; + } + }, [duration, name, nativeCurrency, yearsDuration]); + + const { data, status, isIdle, isLoading } = useQuery( + ['getRegistrationValues', [duration, name, nativeCurrency]], + getRegistrationValues, + { enabled: isValidLength, retry: 0, staleTime: Infinity } + ); + + const isAvailable = status === 'success' && data?.available === true; + const isRegistered = status === 'success' && data?.available === false; + const isInvalid = status === 'success' && !data?.valid; + + return { + data, + isAvailable, + isIdle, + isInvalid, + isLoading, + isRegistered, + }; +} diff --git a/src/hooks/useExternalWalletSectionsData.ts b/src/hooks/useExternalWalletSectionsData.ts new file mode 100644 index 00000000000..51da9acae92 --- /dev/null +++ b/src/hooks/useExternalWalletSectionsData.ts @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; +import useFetchShowcaseTokens from './useFetchShowcaseTokens'; +import useFetchUniqueTokens from './useFetchUniqueTokens'; +import { buildBriefUniqueTokenList } from '@rainbow-me/helpers/assets'; + +export default function useExternalWalletSectionsData({ + address, +}: { + address?: string; +}) { + const { + data: uniqueTokens, + isLoading: isUniqueTokensLoading, + isSuccess: isUniqueTokensSuccess, + } = useFetchUniqueTokens({ address }); + const { data: showcaseTokens } = useFetchShowcaseTokens({ address }); + + const sellingTokens = useMemo( + () => uniqueTokens?.filter(token => token.currentPrice) || [], + [uniqueTokens] + ); + + const briefSectionsData = useMemo( + () => + uniqueTokens + ? buildBriefUniqueTokenList(uniqueTokens, showcaseTokens, sellingTokens) + : [], + [uniqueTokens, showcaseTokens, sellingTokens] + ); + + return { + briefSectionsData, + isLoading: isUniqueTokensLoading, + isSuccess: isUniqueTokensSuccess, + }; +} diff --git a/src/hooks/useFadeImage.ts b/src/hooks/useFadeImage.ts new file mode 100644 index 00000000000..11ce04b19b0 --- /dev/null +++ b/src/hooks/useFadeImage.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Source } from 'react-native-fast-image'; +import { + Easing, + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { ImgixImage } from '@rainbow-me/images'; + +export default function useFadeImage({ + source, + enabled = true, +}: { + source?: Source; + enabled?: boolean; +}) { + const [isLoading, setIsLoading] = useState(false); + + const opacity = useSharedValue(1); + + useEffect(() => { + (async () => { + if (source) { + const cachedPath = await ImgixImage.getCachePath(source); + setIsLoading(!cachedPath); + } + })(); + }, [source]); + + const style = useAnimatedStyle(() => { + return { + opacity: withTiming(opacity.value, { + duration: 100, + easing: Easing.linear, + }), + }; + }); + + useEffect(() => { + if (enabled && !source) { + setIsLoading(false); + } + }, [enabled, source]); + + const onLoadEnd = useCallback(() => { + setIsLoading(false); + }, []); + + useAnimatedReaction( + () => ({ enabled, isLoading }), + ({ isLoading, enabled }) => { + opacity.value = isLoading || !enabled ? 0 : 1; + }, + [isLoading, enabled] + ); + + return { isLoading, onLoadEnd, style }; +} diff --git a/src/hooks/useFetchShowcaseTokens.ts b/src/hooks/useFetchShowcaseTokens.ts new file mode 100644 index 00000000000..7c8548b7509 --- /dev/null +++ b/src/hooks/useFetchShowcaseTokens.ts @@ -0,0 +1,41 @@ +import { useQuery } from 'react-query'; +import useAccountSettings from './useAccountSettings'; +import { getShowcaseTokens } from '@rainbow-me/handlers/localstorage/accountLocal'; +import { getPreference } from '@rainbow-me/model/preferences'; + +export const showcaseTokensQueryKey = ({ address }: { address?: string }) => [ + 'showcase-tokens', + address, +]; + +export default function useFetchShowcaseTokens({ + address, +}: { + address?: string; +}) { + const { network } = useAccountSettings(); + + return useQuery( + showcaseTokensQueryKey({ address }), + async () => { + if (!address) return; + + let showcaseTokens = await getShowcaseTokens(address, network); + const showcaseTokensFromCloud = (await getPreference( + 'showcase', + address + )) as any | undefined; + if ( + showcaseTokensFromCloud?.showcase?.ids && + showcaseTokensFromCloud?.showcase?.ids.length > 0 + ) { + showcaseTokens = showcaseTokensFromCloud.showcase.ids; + } + + return showcaseTokens; + }, + { + enabled: Boolean(address), + } + ); +} diff --git a/src/hooks/useFetchUniqueTokens.ts b/src/hooks/useFetchUniqueTokens.ts new file mode 100644 index 00000000000..22e9991c08f --- /dev/null +++ b/src/hooks/useFetchUniqueTokens.ts @@ -0,0 +1,176 @@ +import { uniqBy } from 'lodash'; +import { useEffect, useState } from 'react'; +import { useQuery, useQueryClient } from 'react-query'; +import useAccountSettings from './useAccountSettings'; +import { UniqueAsset } from '@rainbow-me/entities'; +import { fetchEnsTokens } from '@rainbow-me/handlers/ens'; +import { + getUniqueTokens, + saveUniqueTokens, +} from '@rainbow-me/handlers/localstorage/accountLocal'; +import { + apiGetAccountUniqueTokens, + UNIQUE_TOKENS_LIMIT_PER_PAGE, + UNIQUE_TOKENS_LIMIT_TOTAL, +} from '@rainbow-me/handlers/opensea-api'; +import { Network } from '@rainbow-me/helpers/networkTypes'; + +export const uniqueTokensQueryKey = ({ address }: { address?: string }) => [ + 'unique-tokens', + address, +]; + +const STALE_TIME = 10000; + +export default function useFetchUniqueTokens({ + address, +}: { + address?: string; +}) { + const { network } = useAccountSettings(); + + const [shouldFetchMore, setShouldFetchMore] = useState(false); + + // Get unique tokens from device storage + const [hasStoredTokens, setHasStoredTokens] = useState(false); + useEffect(() => { + (async () => { + try { + const { hasStoredTokens } = await getStoredUniqueTokens({ + address, + network, + }); + setHasStoredTokens(hasStoredTokens); + // eslint-disable-next-line no-empty + } catch (e) {} + })(); + }, [address, network]); + + // Make the first query to retrive the unique tokens. + const uniqueTokensQuery = useQuery( + uniqueTokensQueryKey({ address }), + async () => { + if (!address) return; + + const { storedTokens, hasStoredTokens } = await getStoredUniqueTokens({ + address, + network, + }); + + let uniqueTokens = storedTokens; + if (!hasStoredTokens) { + uniqueTokens = await apiGetAccountUniqueTokens(network, address, 0); + } + + setShouldFetchMore(true); + + return uniqueTokens; + }, + { + enabled: Boolean(address), + staleTime: STALE_TIME, + } + ); + const uniqueTokens = uniqueTokensQuery.data; + + const queryClient = useQueryClient(); + useEffect(() => { + if (!address) return; + + async function fetchMore({ + network, + uniqueTokens = [], + page = 0, + }: { + network: Network; + uniqueTokens?: UniqueAsset[]; + page?: number; + }): Promise { + if ( + uniqueTokens?.length >= page * UNIQUE_TOKENS_LIMIT_PER_PAGE && + uniqueTokens?.length < UNIQUE_TOKENS_LIMIT_TOTAL + ) { + const moreUniqueTokens = await apiGetAccountUniqueTokens( + network, + address as string, + page + ); + if (!hasStoredTokens) { + queryClient.setQueryData( + uniqueTokensQueryKey({ address }), + tokens => + tokens ? [...tokens, ...moreUniqueTokens] : moreUniqueTokens + ); + } + return fetchMore({ + network, + page: page + 1, + uniqueTokens: [...uniqueTokens, ...moreUniqueTokens], + }); + } + return uniqueTokens; + } + + // We have already fetched the first page of results – so let's fetch more! + if (shouldFetchMore && uniqueTokens && uniqueTokens.length > 0) { + setShouldFetchMore(false); + (async () => { + // Fetch more Ethereum tokens until all have fetched + let tokens = await fetchMore({ + network, + // If there are stored tokens in storage, then we want + // to do a background refresh. + page: hasStoredTokens ? 0 : 1, + uniqueTokens: hasStoredTokens ? [] : uniqueTokens, + }); + + // Fetch Polygon tokens until all have fetched + const polygonTokens = await fetchMore({ network: Network.polygon }); + tokens = [...tokens, ...polygonTokens]; + + // Fetch recently registered ENS tokens (OpenSea doesn't recognize these for a while). + // We will fetch tokens registered in the past 48 hours to be safe. + const ensTokens = await fetchEnsTokens({ + address, + timeAgo: { hours: 48 }, + }); + if (ensTokens.length > 0) { + tokens = uniqBy([...tokens, ...ensTokens], 'id'); + } + + if (hasStoredTokens) { + queryClient.setQueryData( + uniqueTokensQueryKey({ address }), + tokens + ); + } + + await saveUniqueTokens(tokens, address, network); + })(); + } + }, [ + address, + shouldFetchMore, + network, + uniqueTokens, + queryClient, + hasStoredTokens, + ]); + + return uniqueTokensQuery; +} + +async function getStoredUniqueTokens({ + address, + network, +}: { + address?: string; + network: Network; +}) { + const storedTokens = await getUniqueTokens(address, network); + const hasStoredTokens = storedTokens && storedTokens.length > 0; + return { + hasStoredTokens, + storedTokens, + }; +} diff --git a/src/hooks/useFirstTransactionTimestamp.ts b/src/hooks/useFirstTransactionTimestamp.ts new file mode 100644 index 00000000000..d7106b37227 --- /dev/null +++ b/src/hooks/useFirstTransactionTimestamp.ts @@ -0,0 +1,24 @@ +import { useQuery } from 'react-query'; +import { web3Provider } from '@rainbow-me/handlers/web3'; +import { getFirstTransactionTimestamp } from '@rainbow-me/utils/ethereumUtils'; + +export default function useFirstTransactionTimestamp({ + ensName, +}: { + ensName: string; +}) { + return useQuery( + ['first-transaction-timestamp', ensName], + async () => { + const address = await web3Provider.resolveName(ensName); + return address ? getFirstTransactionTimestamp(address) : undefined; + }, + { + cacheTime: Infinity, + enabled: Boolean(ensName), + // First transaction timestamp will obviously never be stale. + // So we won't fetch / refetch it again. + staleTime: Infinity, + } + ); +} diff --git a/src/hooks/useImagePicker.tsx b/src/hooks/useImagePicker.tsx new file mode 100644 index 00000000000..432a279129f --- /dev/null +++ b/src/hooks/useImagePicker.tsx @@ -0,0 +1,33 @@ +import lang from 'i18n-js'; +import { useCallback } from 'react'; +import { Linking } from 'react-native'; +import ImagePicker, { Options } from 'react-native-image-crop-picker'; +import { Alert } from '../components/alerts'; + +export default function useImagePicker() { + const openPicker = useCallback(async (options: Options) => { + let image = null; + try { + image = await ImagePicker.openPicker(options); + } catch (e: any) { + if (e?.message === 'User did not grant library permission.') { + Alert({ + buttons: [ + { style: 'cancel', text: lang.t('image_picker.cancel') }, + { + onPress: Linking.openSettings, + text: lang.t('image_picker.confirm'), + }, + ], + message: lang.t('image_picker.message'), + title: lang.t('image_picker.title'), + }); + } + } + return image; + }, []); + + return { + openPicker, + }; +} diff --git a/src/hooks/useImportingWallet.js b/src/hooks/useImportingWallet.js index 99e00a6bb9d..6fb407655d7 100644 --- a/src/hooks/useImportingWallet.js +++ b/src/hooks/useImportingWallet.js @@ -4,13 +4,17 @@ import { keys } from 'lodash'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, InteractionManager, Keyboard } from 'react-native'; import { IS_TESTING } from 'react-native-dotenv'; +import { useDispatch } from 'react-redux'; import useAccountSettings from './useAccountSettings'; import useInitializeWallet from './useInitializeWallet'; import useIsWalletEthZero from './useIsWalletEthZero'; import useMagicAutofocus from './useMagicAutofocus'; import usePrevious from './usePrevious'; import useTimeout from './useTimeout'; +import useWalletENSAvatar from './useWalletENSAvatar'; import useWallets from './useWallets'; +import { PROFILES, useExperimentalFlag } from '@rainbow-me/config'; +import { fetchImages, fetchReverseRecord } from '@rainbow-me/handlers/ens'; import { resolveUnstoppableDomain, web3Provider, @@ -22,12 +26,14 @@ import { } from '@rainbow-me/helpers/validators'; import WalletBackupStepTypes from '@rainbow-me/helpers/walletBackupStepTypes'; import walletLoadingStates from '@rainbow-me/helpers/walletLoadingStates'; +import { walletInit } from '@rainbow-me/model/wallet'; import { Navigation, useNavigation } from '@rainbow-me/navigation'; +import { walletsLoadState } from '@rainbow-me/redux/wallets'; import Routes from '@rainbow-me/routes'; import { ethereumUtils, sanitizeSeedPhrase } from '@rainbow-me/utils'; import logger from 'logger'; -export default function useImportingWallet() { +export default function useImportingWallet({ showImportModal = true } = {}) { const { accountAddress } = useAccountSettings(); const { selectedWallet, setIsWalletLoading, wallets } = useWallets(); @@ -38,11 +44,14 @@ export default function useImportingWallet() { const [seedPhrase, setSeedPhrase] = useState(''); const [color, setColor] = useState(null); const [name, setName] = useState(null); + const [image, setImage] = useState(null); const [busy, setBusy] = useState(false); const [checkedWallet, setCheckedWallet] = useState(null); const [resolvedAddress, setResolvedAddress] = useState(null); const [startAnalyticsTimeout] = useTimeout(); const wasImporting = usePrevious(isImporting); + const { updateWalletENSAvatars } = useWalletENSAvatar(); + const profilesEnabled = useExperimentalFlag(PROFILES); const inputRef = useRef(null); @@ -74,33 +83,41 @@ export default function useImportingWallet() { [isImporting] ); - const showWalletProfileModal = useCallback( - (name, forceColor, address = null) => { - android && Keyboard.dismiss(); - navigate(Routes.MODAL_SCREEN, { - actionType: 'Import', - additionalPadding: true, - address, - asset: [], - forceColor, - isNewProfile: true, - onCloseModal: ({ color, name }) => { - InteractionManager.runAfterInteractions(() => { - if (color !== null) setColor(color); - if (name) setName(name); - handleSetImporting(true); - }); - }, - profile: { name }, - type: 'wallet_profile', - withoutStatusBar: true, - }); + const startImportProfile = useCallback( + (name, forceColor, address = null, avatarUrl) => { + const importWallet = (color, name, image) => + InteractionManager.runAfterInteractions(() => { + if (color !== null) setColor(color); + if (name) setName(name); + if (image) setImage(image); + handleSetImporting(true); + }); + + if (showImportModal) { + android && Keyboard.dismiss(); + navigate(Routes.MODAL_SCREEN, { + actionType: 'Import', + additionalPadding: true, + address, + asset: [], + forceColor, + isNewProfile: true, + onCloseModal: ({ color, name, image }) => { + importWallet(color, name, image); + }, + profile: { image: avatarUrl, name }, + type: 'wallet_profile', + withoutStatusBar: true, + }); + } else { + importWallet(name, forceColor, avatarUrl); + } }, - [handleSetImporting, navigate] + [handleSetImporting, navigate, showImportModal] ); const handlePressImportButton = useCallback( - async (forceColor, forceAddress, forceEmoji = null) => { + async (forceColor, forceAddress, forceEmoji = null, avatarUrl) => { analytics.track('Tapped "Import" button'); // guard against pressEvent coming in as forceColor if // handlePressImportButton is used as onClick handler @@ -114,14 +131,18 @@ export default function useImportingWallet() { // Validate ENS if (isENSAddressFormat(input)) { try { - const address = await web3Provider.resolveName(input); + const [address, images] = await Promise.all([ + web3Provider.resolveName(input), + !avatarUrl && profilesEnabled && fetchImages(input), + ]); if (!address) { Alert.alert('This is not a valid ENS name'); return; } setResolvedAddress(address); name = forceEmoji ? `${forceEmoji} ${input}` : input; - showWalletProfileModal(name, guardedForceColor, address); + avatarUrl = avatarUrl || images?.avatarUrl; + startImportProfile(name, guardedForceColor, address, avatarUrl); analytics.track('Show wallet profile modal for ENS address', { address, input, @@ -142,7 +163,7 @@ export default function useImportingWallet() { } setResolvedAddress(address); name = forceEmoji ? `${forceEmoji} ${input}` : input; - showWalletProfileModal(name, guardedForceColor, address); + startImportProfile(name, guardedForceColor, address); analytics.track('Show wallet profile modal for Unstoppable address', { address, input, @@ -158,6 +179,10 @@ export default function useImportingWallet() { const ens = await web3Provider.lookupAddress(input); if (ens && ens !== input) { name = forceEmoji ? `${forceEmoji} ${ens}` : ens; + if (!avatarUrl && profilesEnabled) { + const images = await fetchImages(name); + avatarUrl = images?.avatarUrl; + } } analytics.track('Show wallet profile modal for read only wallet', { ens, @@ -166,7 +191,7 @@ export default function useImportingWallet() { } catch (e) { logger.log(`Error resolving ENS during wallet import`, e); } - showWalletProfileModal(name, guardedForceColor, input); + startImportProfile(name, guardedForceColor, input); } else { try { setBusy(true); @@ -175,15 +200,20 @@ export default function useImportingWallet() { input ); setCheckedWallet(walletResult); - const ens = await web3Provider.lookupAddress(walletResult.address); + const ens = await fetchReverseRecord(walletResult.address); if (ens && ens !== input) { name = forceEmoji ? `${forceEmoji} ${ens}` : ens; + if (!avatarUrl && profilesEnabled) { + const images = await fetchImages(name); + avatarUrl = images?.avatarUrl; + } } setBusy(false); - showWalletProfileModal( + startImportProfile( name, guardedForceColor, - walletResult.address + walletResult.address, + avatarUrl ); analytics.track('Show wallet profile modal for imported wallet', { address: walletResult.address, @@ -196,9 +226,11 @@ export default function useImportingWallet() { } } }, - [isSecretValid, seedPhrase, showWalletProfileModal] + [isSecretValid, profilesEnabled, seedPhrase, startImportProfile] ); + const dispatch = useDispatch(); + useEffect(() => { if (!wasImporting && isImporting) { startAnalyticsTimeout(async () => { @@ -206,65 +238,83 @@ export default function useImportingWallet() { ? resolvedAddress : sanitizeSeedPhrase(seedPhrase); - const previousWalletCount = keys(wallets).length; - initializeWallet( - input, - color, - name ? name : '', - false, - false, - checkedWallet - ) - .then(success => { - handleSetImporting(false); - if (success) { - goBack(); - InteractionManager.runAfterInteractions(async () => { - if (previousWalletCount === 0) { - replace(Routes.SWIPE_LAYOUT, { - params: { initialized: true }, - screen: Routes.WALLET_SCREEN, - }); - } else { - navigate(Routes.WALLET_SCREEN, { initialized: true }); - } - - setTimeout(() => { - // If it's not read only, show the backup sheet - if ( - !( - isENSAddressFormat(input) || - isUnstoppableAddressFormat(input) || - isValidAddress(input) - ) - ) { - IS_TESTING !== 'true' && - Navigation.handleAction(Routes.BACKUP_SHEET, { - single: true, - step: WalletBackupStepTypes.imported, - }); + if (!showImportModal) { + await walletInit( + input, + color, + name ? name : '', + false, + checkedWallet, + undefined, + image, + true + ); + await dispatch(walletsLoadState(profilesEnabled)); + handleSetImporting(false); + } else { + const previousWalletCount = keys(wallets).length; + initializeWallet( + input, + color, + name ? name : '', + false, + false, + checkedWallet, + undefined, + image + ) + .then(success => { + handleSetImporting(false); + if (success) { + goBack(); + InteractionManager.runAfterInteractions(async () => { + if (previousWalletCount === 0) { + replace(Routes.SWIPE_LAYOUT, { + params: { initialized: true }, + screen: Routes.WALLET_SCREEN, + }); + } else { + navigate(Routes.WALLET_SCREEN, { initialized: true }); } - }, 1000); - analytics.track('Imported seed phrase', { - isWalletEthZero, + + setTimeout(() => { + // If it's not read only, show the backup sheet + if ( + !( + isENSAddressFormat(input) || + isUnstoppableAddressFormat(input) || + isValidAddress(input) + ) + ) { + IS_TESTING !== 'true' && + Navigation.handleAction(Routes.BACKUP_SHEET, { + single: true, + step: WalletBackupStepTypes.imported, + }); + } + }, 1000); + + analytics.track('Imported seed phrase', { + isWalletEthZero, + }); }); - }); - } else { - // Wait for error messages then refocus + } else { + // Wait for error messages then refocus + setTimeout(() => { + inputRef.current?.focus(); + initializeWallet(); + }, 100); + } + }) + .catch(error => { + handleSetImporting(false); + logger.error('error importing seed phrase: ', error); setTimeout(() => { inputRef.current?.focus(); initializeWallet(); }, 100); - } - }) - .catch(error => { - handleSetImporting(false); - logger.error('error importing seed phrase: ', error); - setTimeout(() => { - inputRef.current?.focus(); - initializeWallet(); - }, 100); - }); + }); + } }, 50); } }, [ @@ -285,13 +335,22 @@ export default function useImportingWallet() { startAnalyticsTimeout, wallets, wasImporting, + updateWalletENSAvatars, + image, + dispatch, + showImportModal, + profilesEnabled, ]); useEffect(() => { setIsWalletLoading( - isImporting ? walletLoadingStates.IMPORTING_WALLET : null + isImporting + ? showImportModal + ? walletLoadingStates.IMPORTING_WALLET + : walletLoadingStates.IMPORTING_WALLET_SILENTLY + : null ); - }, [isImporting, setIsWalletLoading]); + }, [isImporting, setIsWalletLoading, showImportModal]); return { busy, @@ -299,6 +358,7 @@ export default function useImportingWallet() { handlePressImportButton, handleSetSeedPhrase, inputRef, + isImporting, isSecretValid, seedPhrase, }; diff --git a/src/hooks/useInitializeWallet.js b/src/hooks/useInitializeWallet.js index f0a195e64fb..ed5ee19bf42 100644 --- a/src/hooks/useInitializeWallet.js +++ b/src/hooks/useInitializeWallet.js @@ -21,6 +21,7 @@ import useLoadAccountData from './useLoadAccountData'; import useLoadGlobalEarlyData from './useLoadGlobalEarlyData'; import useOpenSmallBalances from './useOpenSmallBalances'; import useResetAccountState from './useResetAccountState'; +import { PROFILES, useExperimentalFlag } from '@rainbow-me/config'; import { runKeychainIntegrityChecks } from '@rainbow-me/handlers/walletReadyEvents'; import { additionalDataCoingeckoIds } from '@rainbow-me/redux/additionalAssetsData'; import { checkPendingTransactionsOnInitialize } from '@rainbow-me/redux/data'; @@ -35,6 +36,7 @@ export default function useInitializeWallet() { const { network } = useAccountSettings(); const hideSplashScreen = useHideSplashScreen(); const { setIsSmallBalancesOpen } = useOpenSmallBalances(); + const profilesEnabled = useExperimentalFlag(PROFILES); const initializeWallet = useCallback( async ( @@ -44,14 +46,15 @@ export default function useInitializeWallet() { shouldRunMigrations = false, overwrite = false, checkedWallet = null, - switching + switching, + image, + silent = false ) => { try { PerformanceTracking.startMeasuring( PerformanceMetrics.useInitializeWallet ); logger.sentry('Start wallet setup'); - await resetAccountState(); logger.sentry('resetAccountState ran ok'); @@ -60,7 +63,7 @@ export default function useInitializeWallet() { if (shouldRunMigrations && !seedPhrase) { logger.sentry('shouldRunMigrations && !seedPhrase? => true'); - await dispatch(walletsLoadState()); + await dispatch(walletsLoadState(profilesEnabled)); logger.sentry('walletsLoadState call #1'); await runMigrations(); logger.sentry('done with migrations'); @@ -77,7 +80,9 @@ export default function useInitializeWallet() { name, overwrite, checkedWallet, - network + network, + image, + silent ); logger.sentry('walletInit returned ', { @@ -93,7 +98,7 @@ export default function useInitializeWallet() { if (seedPhrase || isNew) { logger.sentry('walletsLoadState call #2'); - await dispatch(walletsLoadState()); + await dispatch(walletsLoadState(profilesEnabled)); } if (isNil(walletAddress)) { @@ -162,6 +167,7 @@ export default function useInitializeWallet() { loadAccountData, loadGlobalEarlyData, network, + profilesEnabled, resetAccountState, setIsSmallBalancesOpen, ] diff --git a/src/hooks/useInterval.js b/src/hooks/useInterval.ts similarity index 69% rename from src/hooks/useInterval.js rename to src/hooks/useInterval.ts index 96668d0d262..915fb4ef0a3 100644 --- a/src/hooks/useInterval.js +++ b/src/hooks/useInterval.ts @@ -1,19 +1,19 @@ import { useCallback, useEffect, useRef } from 'react'; export default function useInterval() { - const handle = useRef(); + const handle = useRef(); const start = useCallback((func, ms) => { handle.current = setInterval(func, ms); }, []); const stop = useCallback( - () => handle.current && clearInterval(handle.current), + () => (handle.current ? clearInterval(handle.current) : undefined), [] ); // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => () => stop(), []); - return [start, stop, handle]; + return [start, stop, handle] as const; } diff --git a/src/hooks/useLoadAccountData.js b/src/hooks/useLoadAccountData.js index 705e5441fb3..a0e05701568 100644 --- a/src/hooks/useLoadAccountData.js +++ b/src/hooks/useLoadAccountData.js @@ -11,6 +11,7 @@ import { uniswapLiquidityLoadState } from '../redux/uniswapLiquidity'; import { uniswapPositionsLoadState } from '../redux/usersPositions'; import { walletConnectLoadState } from '../redux/walletconnect'; import { promiseUtils } from '../utils'; +import { ensRegistrationsLoadState } from '@rainbow-me/redux/ensRegistration'; import logger from 'logger'; export default function useLoadAccountData() { @@ -31,7 +32,8 @@ export default function useLoadAccountData() { const p6 = dispatch(addCashLoadState()); const p7 = dispatch(uniswapLiquidityLoadState()); const p8 = dispatch(uniswapPositionsLoadState()); - promises.push(p3, p4, p5, p6, p7, p8); + const p9 = dispatch(ensRegistrationsLoadState()); + promises.push(p3, p4, p5, p6, p7, p8, p9); return promiseUtils.PromiseAllWithFails(promises); }, diff --git a/src/hooks/useLoadAccountLateData.ts b/src/hooks/useLoadAccountLateData.ts new file mode 100644 index 00000000000..d36382d40cc --- /dev/null +++ b/src/hooks/useLoadAccountLateData.ts @@ -0,0 +1,18 @@ +import { useCallback } from 'react'; +import { promiseUtils } from '../utils'; +import { prefetchAccountENSDomains } from './useAccountENSDomains'; +import useAccountSettings from './useAccountSettings'; +import logger from 'logger'; + +export default function useLoadAccountLateData() { + const { accountAddress } = useAccountSettings(); + + const load = useCallback(async () => { + logger.sentry('Load wallet account late data'); + return promiseUtils.PromiseAllWithFails([ + prefetchAccountENSDomains({ accountAddress }), + ]); + }, [accountAddress]); + + return load; +} diff --git a/src/hooks/useMagicAutofocus.js b/src/hooks/useMagicAutofocus.js index 3a334f0d02e..949b7cce8f6 100644 --- a/src/hooks/useMagicAutofocus.js +++ b/src/hooks/useMagicAutofocus.js @@ -21,7 +21,8 @@ export function delayNext() { export default function useMagicAutofocus( defaultAutofocusInputRef, customTriggerFocusCallback, - shouldFocusOnNavigateOnAndroid = false + shouldFocusOnNavigateOnAndroid = false, + showAfterInteractions = false ) { const isScreenFocused = useIsFocused(); const lastFocusedInputHandle = useRef(null); @@ -78,7 +79,11 @@ export default function useMagicAutofocus( }, 200); }); } else { - triggerFocus(); + if (showAfterInteractions) { + InteractionManager.runAfterInteractions(triggerFocus); + } else { + triggerFocus(); + } } // We need to do this in order to assure that the input gets focused @@ -90,7 +95,12 @@ export default function useMagicAutofocus( return () => { setListener(null); }; - }, [fallbackRefocusLastInput, shouldFocusOnNavigateOnAndroid, triggerFocus]) + }, [ + fallbackRefocusLastInput, + shouldFocusOnNavigateOnAndroid, + showAfterInteractions, + triggerFocus, + ]) ); return { diff --git a/src/hooks/useOnAvatarPress.ts b/src/hooks/useOnAvatarPress.ts index 869818ed0d2..c752713cb73 100644 --- a/src/hooks/useOnAvatarPress.ts +++ b/src/hooks/useOnAvatarPress.ts @@ -1,19 +1,29 @@ +import analytics from '@segment/analytics-react-native'; +import lang from 'i18n-js'; import { toLower } from 'lodash'; import { useCallback, useMemo } from 'react'; import { Linking } from 'react-native'; -import ImagePicker from 'react-native-image-crop-picker'; import { useDispatch } from 'react-redux'; import { RainbowAccount } from '../model/wallet'; import { useNavigation } from '../navigation/Navigation'; import useAccountProfile from './useAccountProfile'; -import useUpdateEmoji from './useUpdateEmoji'; +import useENSProfile from './useENSProfile'; +import { prefetchENSProfileImages } from './useENSProfileImages'; +import useENSRegistration from './useENSRegistration'; +import useImagePicker from './useImagePicker'; import useWallets from './useWallets'; +import { + enableActionsOnReadOnlyWallet, + PROFILES, + useExperimentalFlag, +} from '@rainbow-me/config'; +import { REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; import { walletsSetSelected, walletsUpdate } from '@rainbow-me/redux/wallets'; import Routes from '@rainbow-me/routes'; import { buildRainbowUrl, showActionSheetWithOptions } from '@rainbow-me/utils'; export default () => { - const { wallets, selectedWallet } = useWallets(); + const { wallets, selectedWallet, isReadOnlyWallet } = useWallets(); const dispatch = useDispatch(); const { navigate } = useNavigation(); const { @@ -23,6 +33,10 @@ export default () => { accountImage, accountENS, } = useAccountProfile(); + const profilesEnabled = useExperimentalFlag(PROFILES); + const profileEnabled = Boolean(accountENS); + const ensProfile = useENSProfile(accountENS, { enabled: profileEnabled }); + const { openPicker } = useImagePicker(); const onAvatarRemovePhoto = useCallback(async () => { const newWallets = { @@ -73,12 +87,17 @@ export default () => { }); }, [accountColor, accountName, navigate]); - const onAvatarChooseImage = useCallback(() => { - ImagePicker.openPicker({ + const onAvatarChooseImage = useCallback(async () => { + const image = await openPicker({ cropperCircleOverlay: true, cropping: true, - }).then(processPhoto); - }, [processPhoto]); + }); + processPhoto(image); + }, [openPicker, processPhoto]); + + const onAvatarCreateProfile = useCallback(() => { + navigate(Routes.REGISTER_ENS_NAVIGATOR); + }, [navigate]); const onAvatarWebProfile = useCallback(() => { const rainbowURL = buildRainbowUrl(null, accountENS, accountAddress); @@ -87,48 +106,98 @@ export default () => { } }, [accountAddress, accountENS]); - const { setNextEmoji } = useUpdateEmoji(); + const { startRegistration } = useENSRegistration(); const onAvatarPress = useCallback(() => { - if (android) { - setNextEmoji(); - return; + if (profileEnabled && !ensProfile?.isSuccess) return; + + const isENSProfile = + profilesEnabled && profileEnabled && ensProfile?.isOwner; + + if (isENSProfile) { + // Prefetch profile images + prefetchENSProfileImages({ name: accountENS }); } - const avatarActionSheetOptions = [ - 'Choose from Library', - ...(!accountImage ? ['Pick an Emoji'] : []), - ...(accountImage ? ['Remove Photo'] : []), - ...(ios ? ['Cancel'] : []), - ]; - showActionSheetWithOptions( - { - cancelButtonIndex: avatarActionSheetOptions.length - 1, - destructiveButtonIndex: accountImage - ? avatarActionSheetOptions.length - 2 - : undefined, - options: avatarActionSheetOptions, - }, - async (buttonIndex: Number) => { + const avatarActionSheetOptions = (isENSProfile + ? [ + lang.t('profiles.profile_avatar.view_profile'), + (!isReadOnlyWallet || enableActionsOnReadOnlyWallet) && + lang.t('profiles.profile_avatar.edit_profile'), + ] + : [ + lang.t('profiles.profile_avatar.choose_from_library'), + !accountImage && lang.t(`profiles.profile_avatar.pick_emoji`), + (!isReadOnlyWallet || enableActionsOnReadOnlyWallet) && + lang.t('profiles.profile_avatar.create_profile'), + !!accountImage && lang.t(`profiles.profile_avatar.remove_photo`), + ] + ) + .filter(option => Boolean(option)) + .concat(ios ? ['Cancel'] : []); + + const callback = async (buttonIndex: Number) => { + if (isENSProfile) { + if (buttonIndex === 0) { + navigate(Routes.PROFILE_SHEET, { + address: accountENS, + fromRoute: 'ProfileAvatar', + }); + analytics.track('Viewed ENS profile', { + category: 'profiles', + ens: accountENS, + from: 'Transaction list', + }); + } else if (buttonIndex === 1 && !isReadOnlyWallet) { + startRegistration(accountENS, REGISTRATION_MODES.EDIT); + navigate(Routes.REGISTER_ENS_NAVIGATOR, { + ensName: accountENS, + mode: REGISTRATION_MODES.EDIT, + }); + } + } else { if (buttonIndex === 0) { onAvatarChooseImage(); } else if (buttonIndex === 1) { - if (!accountImage) { + if (accountImage) { + onAvatarRemovePhoto(); + } else { onAvatarPickEmoji(); } + } else if (buttonIndex === 2) { if (accountImage) { onAvatarRemovePhoto(); + } else { + onAvatarCreateProfile(); } } } + }; + + showActionSheetWithOptions( + { + cancelButtonIndex: avatarActionSheetOptions.length - 1, + destructiveButtonIndex: + !isENSProfile && accountImage + ? avatarActionSheetOptions.length - 2 + : undefined, + options: avatarActionSheetOptions, + }, + (buttonIndex: Number) => callback(buttonIndex) ); }, [ - setNextEmoji, + ensProfile, + profileEnabled, + profilesEnabled, + isReadOnlyWallet, accountImage, + navigate, + accountENS, + startRegistration, onAvatarChooseImage, - onAvatarPickEmoji, onAvatarRemovePhoto, - setNextEmoji, + onAvatarPickEmoji, + onAvatarCreateProfile, ]); const avatarOptions = useMemo( @@ -168,6 +237,7 @@ export default () => { return { avatarOptions, onAvatarChooseImage, + onAvatarCreateProfile, onAvatarPickEmoji, onAvatarPress, onAvatarRemovePhoto, diff --git a/src/hooks/usePendingTransactions.ts b/src/hooks/usePendingTransactions.ts new file mode 100644 index 00000000000..9290059d138 --- /dev/null +++ b/src/hooks/usePendingTransactions.ts @@ -0,0 +1,25 @@ +import { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { AppState } from '@rainbow-me/redux/store'; +import { ethereumUtils, isLowerCaseMatch } from '@rainbow-me/utils'; + +export default function usePendingTransactions() { + const pendingTransactions = useSelector( + ({ data }: AppState) => data.pendingTransactions + ); + + const getPendingTransactionByHash = useCallback( + (transactionHash: string) => + pendingTransactions.find(pendingTransaction => + isLowerCaseMatch( + ethereumUtils.getHash(pendingTransaction) || '', + transactionHash + ) + ), + [pendingTransactions] + ); + + return { + getPendingTransactionByHash, + }; +} diff --git a/src/hooks/usePersistentDominantColorFromImage.ts b/src/hooks/usePersistentDominantColorFromImage.ts index de32c5e303b..f8f04e0d3d4 100644 --- a/src/hooks/usePersistentDominantColorFromImage.ts +++ b/src/hooks/usePersistentDominantColorFromImage.ts @@ -27,16 +27,31 @@ export default function usePersistentDominantColorFromImage( (url || '') as string, storage ); + const [state, setState] = useState( - dominantColor ? State.loaded : State.init + dominantColor ? State.loaded : url ? State.loading : State.init ); useEffect(() => { - if (state === State.init && url) { + if (!dominantColor) { + if (url) { + setState(State.loading); + } else { + setState(State.init); + } + } + }, [dominantColor, url]); + + useEffect(() => { + if ((state === State.loading || state === State.init) && url) { setState(State.loading); - getDominantColorFromImage(url, colorToMeasureAgainst).then(color => - // @ts-ignore - setPersistentDominantColor(color) - ); + getDominantColorFromImage(url, colorToMeasureAgainst) + .then(color => { + // @ts-ignore + setPersistentDominantColor(color); + }) + .finally(() => { + setState(State.loaded); + }); } }, [colorToMeasureAgainst, setPersistentDominantColor, state, url]); diff --git a/src/hooks/useRefreshAccountData.js b/src/hooks/useRefreshAccountData.js index 8742b1fb91a..66c3c56d2fc 100644 --- a/src/hooks/useRefreshAccountData.js +++ b/src/hooks/useRefreshAccountData.js @@ -7,9 +7,10 @@ import { fetchOnchainBalances } from '../redux/fallbackExplorer'; import { uniqueTokensRefreshState } from '../redux/uniqueTokens'; import { updatePositions } from '../redux/usersPositions'; import { walletConnectLoadState } from '../redux/walletconnect'; -import { fetchWalletNames } from '../redux/wallets'; +import { fetchWalletENSAvatars, fetchWalletNames } from '../redux/wallets'; import useAccountSettings from './useAccountSettings'; import useSavingsAccount from './useSavingsAccount'; +import { PROFILES, useExperimentalFlag } from '@rainbow-me/config'; import logger from 'logger'; export default function useRefreshAccountData() { @@ -17,6 +18,7 @@ export default function useRefreshAccountData() { const { network } = useAccountSettings(); const { refetchSavings } = useSavingsAccount(); const [isRefreshing, setIsRefreshing] = useState(false); + const profilesEnabled = useExperimentalFlag(PROFILES); const fetchAccountData = useCallback(async () => { // Refresh unique tokens for Rinkeby @@ -32,6 +34,9 @@ export default function useRefreshAccountData() { try { const getWalletNames = dispatch(fetchWalletNames()); + const getWalletENSAvatars = profilesEnabled + ? dispatch(fetchWalletENSAvatars()) + : null; const getUniqueTokens = dispatch(uniqueTokensRefreshState()); const balances = dispatch( fetchOnchainBalances({ keepPolling: false, withPrices: false }) @@ -42,6 +47,7 @@ export default function useRefreshAccountData() { delay(1250), // minimum duration we want the "Pull to Refresh" animation to last getWalletNames, getUniqueTokens, + getWalletENSAvatars, refetchSavings(true), balances, wc, @@ -52,7 +58,7 @@ export default function useRefreshAccountData() { captureException(error); throw error; } - }, [dispatch, network, refetchSavings]); + }, [dispatch, network, profilesEnabled, refetchSavings]); const refresh = useCallback(async () => { if (isRefreshing) return; diff --git a/src/hooks/useSelectImageMenu.tsx b/src/hooks/useSelectImageMenu.tsx new file mode 100644 index 00000000000..a9d2b9b25c0 --- /dev/null +++ b/src/hooks/useSelectImageMenu.tsx @@ -0,0 +1,235 @@ +import { useFocusEffect } from '@react-navigation/native'; +import lang from 'i18n-js'; +import React, { useCallback, useMemo, useRef } from 'react'; +import { Image, Options } from 'react-native-image-crop-picker'; +import { ContextMenuButton } from 'react-native-ios-context-menu'; +import { useMutation } from 'react-query'; +import { useImagePicker } from '.'; +import { UniqueAsset } from '@rainbow-me/entities'; +import { + uploadImage, + UploadImageReturnData, +} from '@rainbow-me/handlers/pinata'; +import { useNavigation } from '@rainbow-me/navigation'; +import Routes from '@rainbow-me/routes'; +import { showActionSheetWithOptions } from '@rainbow-me/utils'; + +type Action = 'library' | 'nft'; + +const items = { + library: { + actionKey: 'library', + actionTitle: lang.t('profiles.create.upload_photo'), + icon: { + imageValue: { + systemName: 'photo.on.rectangle.angled', + }, + type: 'IMAGE_SYSTEM', + }, + }, + nft: { + actionKey: 'nft', + actionTitle: lang.t('profiles.create.choose_nft'), + icon: { + imageValue: { + systemName: 'square.grid.2x2', + }, + testID: 'choose-nft', + type: 'IMAGE_SYSTEM', + }, + }, + remove: { + actionKey: 'remove', + actionTitle: lang.t('profiles.create.remove'), + icon: { + imageValue: { + systemName: 'trash', + }, + type: 'IMAGE_SYSTEM', + }, + menuAttributes: ['destructive'], + }, +} as const; + +export default function useSelectImageMenu({ + imagePickerOptions, + menuItems: initialMenuItems = ['library'], + onChangeImage, + onRemoveImage, + onUploading, + onUploadSuccess, + onUploadError, + showRemove = false, + uploadToIPFS = false, + testID = '', +}: { + imagePickerOptions?: Options; + menuItems?: Action[]; + onChangeImage?: ({ + asset, + image, + }: { + asset?: UniqueAsset; + image?: Image & { tmpPath?: string }; + }) => void; + onRemoveImage?: () => void; + onUploading?: ({ image }: { image: Image }) => void; + onUploadSuccess?: ({ + data, + image, + }: { + data: UploadImageReturnData; + image: Image; + }) => void; + onUploadError?: ({ error, image }: { error: unknown; image: Image }) => void; + showRemove?: boolean; + uploadToIPFS?: boolean; + testID?: string; +} = {}) { + const { navigate, dangerouslyGetParent } = useNavigation(); + const { openPicker } = useImagePicker(); + const { isLoading: isUploading, mutateAsync: upload } = useMutation( + 'ensImageUpload', + uploadImage + ); + + // If the image is removed while uploading, we don't want to + // call `onUploadSuccess` when the upload has finished. + const isRemoved = useRef(false); + + // When this hook is inside a nested navigator, the child + // navigator will still think it is focused. Here, we are + // also checking if the parent has not been dismissed too. + const isFocused = useRef(); + useFocusEffect( + useCallback(() => { + isFocused.current = true; + const dismiss = () => (isFocused.current = false); + // @ts-expect-error `dismiss` is valid event + dangerouslyGetParent()?.addListener('dismiss', dismiss); + return () => { + isFocused.current = false; + // @ts-expect-error `dismiss` is valid event + dangerouslyGetParent()?.removeListener('dismiss', dismiss); + }; + }, [dangerouslyGetParent]) + ); + + const menuItems = useMemo( + () => + [...initialMenuItems, showRemove ? 'remove' : undefined].filter( + Boolean + ) as (Action | 'remove')[], + [initialMenuItems, showRemove] + ); + + const handleSelectImage = useCallback(async () => { + const image = await openPicker({ + ...imagePickerOptions, + includeBase64: true, + mediaType: 'photo', + }); + if (!image) return; + const stringIndex = image?.path.indexOf('/tmp'); + const tmpPath = ios ? `~${image?.path.slice(stringIndex)}` : image?.path; + + onChangeImage?.({ image: { ...image, tmpPath } }); + + if (uploadToIPFS) { + onUploading?.({ image }); + try { + const splitPath = image.path.split('/'); + const filename = + image.filename || splitPath[splitPath.length - 1] || ''; + const data = await upload({ + filename, + mime: image.mime, + path: image.path.replace('file://', ''), + }); + if (!isFocused.current || isRemoved.current) return; + onUploadSuccess?.({ data, image }); + } catch (err) { + if (!isFocused.current || isRemoved.current) return; + onUploadError?.({ error: err, image }); + } + } + }, [ + imagePickerOptions, + isRemoved, + onChangeImage, + onUploadError, + onUploadSuccess, + onUploading, + openPicker, + upload, + uploadToIPFS, + ]); + + const handleSelectNFT = useCallback(() => { + navigate(Routes.SELECT_UNIQUE_TOKEN_SHEET, { + onSelect: (asset: any) => onChangeImage?.({ asset }), + springDamping: 1, + topOffset: 0, + }); + }, [navigate, onChangeImage]); + + const handlePressMenuItem = useCallback( + ({ nativeEvent: { actionKey } }) => { + if (actionKey === 'library') { + handleSelectImage(); + } + if (actionKey === 'nft') { + handleSelectNFT(); + } + if (actionKey === 'remove') { + isRemoved.current = true; + onRemoveImage?.(); + } + }, + [handleSelectImage, handleSelectNFT, onRemoveImage] + ); + + const handleAndroidPress = useCallback(() => { + const actionSheetOptions = menuItems + .map(item => items[item]?.actionTitle) + .filter(Boolean) as any; + + showActionSheetWithOptions( + { + options: actionSheetOptions, + }, + async (buttonIndex: Number) => { + if (buttonIndex === 0) { + handleSelectImage(); + } else if (buttonIndex === 1) { + handleSelectNFT(); + } else if (buttonIndex === 2) { + isRemoved.current = true; + onRemoveImage?.(); + } + } + ); + }, [handleSelectImage, handleSelectNFT, menuItems, onRemoveImage]); + + const ContextMenu = useCallback( + ({ children }) => ( + items[item]) as any, + menuTitle: '', + }} + {...(android ? { onPress: handleAndroidPress } : {})} + isMenuPrimaryAction + onPressMenuItem={handlePressMenuItem} + testID={`use-select-image-${testID}`} + useActionSheetFallback={false} + > + {children} + + ), + [handleAndroidPress, handlePressMenuItem, menuItems, testID] + ); + + return { ContextMenu, isUploading }; +} diff --git a/src/hooks/useTrackENSProfile.ts b/src/hooks/useTrackENSProfile.ts new file mode 100644 index 00000000000..fcab7516b89 --- /dev/null +++ b/src/hooks/useTrackENSProfile.ts @@ -0,0 +1,71 @@ +import analytics from '@segment/analytics-react-native'; +import { useCallback, useMemo } from 'react'; +import { useQuery } from 'react-query'; +import useWallets from './useWallets'; +import { EthereumAddress } from '@rainbow-me/entities'; +import { + fetchAccountRegistrations, + fetchProfile, +} from '@rainbow-me/handlers/ens'; +import { ENS_RECORDS } from '@rainbow-me/helpers/ens'; +import walletTypes from '@rainbow-me/helpers/walletTypes'; + +export default function useTrackENSProfile() { + const { walletNames, wallets } = useWallets(); + + const addresses = useMemo( + () => + Object.values(wallets || {}) + .filter((wallet: any) => wallet?.type !== walletTypes.readOnly) + .reduce( + (addresses: EthereumAddress[], wallet: any) => + addresses.concat( + wallet?.addresses.map( + ({ address }: { address: EthereumAddress }) => address + ) + ), + [] + ), + [wallets] + ); + + const getTrackProfilesData = useCallback(async () => { + const data = { + numberOfENSOwned: 0, + numberOfENSWithAvatarOrCoverSet: 0, + numberOfENSWithOtherMetadataSet: 0, + numberOfENSWithPrimaryNameSet: 0, + }; + for (const i in addresses) { + const ens = walletNames[addresses[i]]; + if (ens) { + const profile = await fetchProfile(ens); + const registrations = await fetchAccountRegistrations(addresses[i]); + data.numberOfENSOwned += + registrations?.data?.account?.registrations?.length || 0; + data.numberOfENSWithAvatarOrCoverSet += + profile?.records?.avatar || profile?.records?.cover ? 1 : 0; + + data.numberOfENSWithOtherMetadataSet = Object.keys( + profile?.records || {} + ).some(key => key !== ENS_RECORDS.cover && key !== ENS_RECORDS.avatar) + ? 1 + : 0; + data.numberOfENSWithPrimaryNameSet += 1; + } + } + return data; + }, [addresses, walletNames]); + + const { data, isSuccess } = useQuery( + ['getTrackProfilesData', [addresses]], + getTrackProfilesData, + { enabled: Boolean(addresses.length), retry: 0 } + ); + + const trackENSProfile = useCallback(() => { + isSuccess && analytics.identify(null, data); + }, [isSuccess, data]); + + return { trackENSProfile }; +} diff --git a/src/hooks/useWalletENSAvatar.ts b/src/hooks/useWalletENSAvatar.ts new file mode 100644 index 00000000000..cf701d7bc3e --- /dev/null +++ b/src/hooks/useWalletENSAvatar.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { PROFILES, useExperimentalFlag } from '@rainbow-me/config'; +import { useWallets } from '@rainbow-me/hooks'; +import { getWalletENSAvatars } from '@rainbow-me/redux/wallets'; + +export default function useWalletENSAvatar() { + const dispatch = useDispatch(); + const profilesEnabled = useExperimentalFlag(PROFILES); + + const { wallets, walletNames, selectedWallet } = useWallets(); + + const updateWalletENSAvatars = useCallback(async () => { + if (!profilesEnabled) return; + await getWalletENSAvatars( + { selected: selectedWallet, walletNames, wallets }, + dispatch + ); + }, [dispatch, profilesEnabled, selectedWallet, walletNames, wallets]); + + return { updateWalletENSAvatars }; +} diff --git a/src/hooks/useWatchWallet.ts b/src/hooks/useWatchWallet.ts new file mode 100644 index 00000000000..06ca0cdfcce --- /dev/null +++ b/src/hooks/useWatchWallet.ts @@ -0,0 +1,98 @@ +import { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { + useAccountProfile, + useDeleteWallet, + useImportingWallet, + useInitializeWallet, + useWallets, +} from '@rainbow-me/hooks'; +import { + addressSetSelected, + walletsSetSelected, +} from '@rainbow-me/redux/wallets'; +import { doesWalletsContainAddress, logger } from '@rainbow-me/utils'; + +export default function useWatchWallet({ + address: primaryAddress, + ensName, + avatarUrl, + showImportModal = true, +}: { + address?: string; + ensName?: string; + avatarUrl?: string | null; + showImportModal?: boolean; +}) { + const dispatch = useDispatch(); + + const { wallets } = useWallets(); + + const watchingWallet = useMemo(() => { + return Object.values(wallets || {}).find((wallet: any) => + wallet.addresses.some(({ address }: any) => address === primaryAddress) + ); + }, [primaryAddress, wallets]); + const isWatching = useMemo(() => Boolean(watchingWallet), [watchingWallet]); + + const deleteWallet = useDeleteWallet({ address: primaryAddress }); + + const initializeWallet = useInitializeWallet(); + const changeAccount = useCallback( + async (walletId, address) => { + const wallet = wallets[walletId]; + try { + const p1 = dispatch(walletsSetSelected(wallet)); + const p2 = dispatch(addressSetSelected(address)); + await Promise.all([p1, p2]); + + initializeWallet(null, null, null, false, false, null, true); + } catch (e) { + logger.log('error while switching account', e); + } + }, + [dispatch, initializeWallet, wallets] + ); + + const { accountAddress } = useAccountProfile(); + const { + isImporting, + handleSetSeedPhrase, + handlePressImportButton, + } = useImportingWallet({ + showImportModal, + }); + const watchWallet = useCallback(async () => { + if (!isWatching) { + handleSetSeedPhrase(ensName); + handlePressImportButton(null, ensName, null, avatarUrl); + } else { + deleteWallet(); + // If we're deleting the selected wallet + // we need to switch to another one + if (primaryAddress && primaryAddress === accountAddress) { + const { wallet: foundWallet, key } = + doesWalletsContainAddress({ + address: primaryAddress, + wallets, + }) || {}; + if (foundWallet) { + await changeAccount(key, foundWallet.address); + } + } + } + }, [ + isWatching, + handleSetSeedPhrase, + ensName, + handlePressImportButton, + avatarUrl, + deleteWallet, + primaryAddress, + accountAddress, + wallets, + changeAccount, + ]); + + return { isImporting, isWatching, watchWallet }; +} diff --git a/src/languages/_english.json b/src/languages/_english.json index 70b9e3210d5..eda1b284455 100644 --- a/src/languages/_english.json +++ b/src/languages/_english.json @@ -212,7 +212,7 @@ "edit": "Edit", "exchange": "Exchange", "exchange_again": "Exchange Again", - "exchange_search_uniswap": "Search Uniswap", + "exchange_search_placeholder": "Search", "go_back": "Go Back", "got_it": "Got it", "hide": "Hide", @@ -262,12 +262,14 @@ "add": "Add Contact", "cancel": "Cancel", "delete": "Delete Contact", - "edit": "Edit Contact" + "edit": "Edit Contact", + "view": "View Profile" }, "send_header": "Send", "suggestions": "Suggestions", "to_header": "To", - "watching": "Watching" + "watching": "Watching", + "input_placeholder": "Name" }, "developer_settings": { "alert": "Alert", @@ -277,6 +279,7 @@ "clear_async_storage": "Clear async storage", "clear_mmkv_storage": "Clear MMKV storage", "clear_image_metadata_cache": "Clear Image Metadata Cache", + "clear_image_cache": "Clear Image Cache", "connect_to_hardhat": "Connect to hardhat", "crash_app_render_error": "Crash app (render error)", "not_applied": "NOT APPLIED", @@ -298,7 +301,7 @@ "trading_at_prefix": "Trading at" }, "search": { - "ethereum_name_service": "Ethereum Name Service", + "profiles": "Profiles", "search_ethereum": "Search all of Ethereum" }, "strategies": { @@ -411,6 +414,10 @@ "total_value": "Total value", "underlying_tokens": "Underlying tokens" }, + "nft_brief_token_info": { + "for_sale": "For sale", + "last_sale": "Last sale price" + }, "supported_countries": { "supported_countries": "Supported Countries", "us_except": "United States (except Texas and New York)" @@ -445,7 +452,18 @@ }, "unique_expanded": { "about": "About %{assetFamilyName}", + "advanced": "Advanced", "attributes": "Attributes", + "configuration": "Configuration", + "copy": "Copy", + "edit": "Edit", + "manager": "Manager", + "owner": "Owner", + "set_primary_name": "Set as my ENS name", + "profile_info": "Profile Info", + "properties": "Properties", + "registrant": "Registrant", + "resolver": "Resolver", "collection_website": "Collection Website", "copy_token_id": "Copy Token ID", "description": "Description", @@ -456,12 +474,15 @@ "last_sale_price": "Last sale price", "open_in_web_browser": "Open in Web Browser", "opensea": "OpenSea", - "properties": "Properties", "save_to_photos": "Save to Photos", "share_token_info": "Share %{uniqueTokenName} Info", "showcase": "Showcase", "toast_added_to_showcase": "Added to showcase", "toast_removed_from_showcase": "Removed from showcase", + "view_on_opensea": "View on Opensea", + "view_on_platform": "View on %{platform}", + "expires_in": "Expires in", + "expires_on": "Expires on", "twitter": "Twitter", "view_all_with_property": "View All With Property", "view_collection": "View Collection", @@ -529,6 +550,9 @@ "short_placeholder": "ENS or address" } }, + "gas": { + "network_fee": "Est. network fee" + }, "homepage": { "back": "Back to rainbow.me", "coming_soon": "Coming soon.", @@ -594,6 +618,12 @@ "private_key": "Private Key", "recipient_address": "Recipient Address" }, + "image_picker": { + "title": "Rainbow would like to access your photos", + "message": "This allows Rainbow to use your photos from your library", + "confirm": "Enable library access", + "cancel": "Cancel" + }, "list": { "nothing_here": "Nothing here!", "share": { @@ -722,6 +752,162 @@ "pools_title": "Pools", "withdraw": "Withdraw" }, + "profiles": { + "actions": { + "watching": "Watching", + "watch": "Watch", + "unwatch_ens": "Unwatch %{ensName}", + "unwatch_ens_title": "Are you sure you want to unwatch %{ensName}?", + "edit_profile": "Edit profile" + }, + "banner": { + "register_name": "Create Your ENS Profile", + "and_create_ens_profile": "Search available .eth names" + }, + "details": { + "add_to_contacts": "Add to Contacts", + "remove_from_contacts": "Remove from Contacts", + "copy_address": "Copy Address", + "view_on_etherscan": "View on Etherscan" + }, + "intro": { + "create_your": "Create Your", + "ens_profile": "ENS Profile", + "wallet_address_info": { + "title": "A better wallet address", + "description": "Send to ENS names instead of hard-to-remember wallet addresses." + }, + "portable_identity_info": { + "title": "A portable digital identity", + "description": "Carry your ENS name and profile between websites. No more signups." + }, + "stored_on_blockchain_info": { + "title": "Stored on Ethereum", + "description": "Your profile is stored directly on Ethereum and owned by you." + }, + "find_your_name": "Find your name", + "use_name": "Use %{name}", + "use_existing_name": "Use an existing ENS name", + "search_new_name": "Search for a new ENS name", + "choose_another_name": "Choose another ENS name", + "my_ens_names": "My ENS Names", + "search_new_ens": "Find a New Name" + }, + "select_ens_name": "My ENS Names", + "search": { + "header": "Find your name", + "description": "Search available ENS names", + "available": "🥳 Available", + "taken": "😭 Taken", + "registered_on": "This name was last registered on %{content}", + "price": "%{content} / Year", + "expiration": "Til %{content}", + "3_char_min": "Minimum 3 characters", + "estimated_total_cost_1": "Estimated total cost of", + "estimated_total_cost_2": "with current network fees", + "loading_fees": "Loading network fees…", + "clear": "􀅉 Clear", + "continue": "Continue 􀆊" + }, + "create": { + "label": "Create your profile", + "add_cover": "Add Cover", + "name": "Name", + "bio": "Bio", + "website": "Website", + "back": "􀆉 Back", + "skip": "Skip", + "username_placeholder": "username", + "name_placeholder": "Add a display name", + "bio_placeholder": "Add a bio to your profile", + "website_placeholder": "Add your website", + "review": "Review", + "cancel": "Cancel", + "choose_nft": "Choose an NFT", + "upload_photo": "Upload a Photo", + "eth": "Ethereum", + "btc": "Bitcoin", + "ltc": "Litecoin", + "doge": "Dogecoin", + "wallet_placeholder": "Add a %{coin} address", + "content": "Content", + "content_placeholder": "Add a content hash", + "notice": "Notice", + "notice_placeholder": "Add a notice", + "keywords": "Keywords", + "keywords_placeholder": "Add keywords", + "reddit": "Reddit", + "twitter": "Twitter", + "telegram": "Telegram", + "pronouns": "Pronouns", + "pronouns_placeholder": "Add pronouns", + "email": "Email", + "github": "GitHub", + "instagram": "Instagram", + "snapchat": "Snapchat", + "discord": "Discord", + "email_placeholder": "Add your email", + "email_submit_message": "Please enter a valid email", + "website_submit_message": "Please enter a valid email", + "invalid_asset": "Invalid %{coin} address", + "invalid_content_hash": "Invalid content hash", + "remove": "Remove", + "email_message": "Please enter a valid email", + "website_message": "Please enter a valid website URL" + }, + "confirm": { + "confirm_purchase": "Confirm purchase", + "registration_details": "Registration Details", + "requesting_register": "Requesting to Register", + "reserving_name": "Reserving Your Name", + "confirm_registration": "Confirm Registration", + "set_name_registration": "Set as Primary Name", + "confirm_set_name": "Confirm Set Primary Name", + "start_registration": "Start Registration", + "confirm_update": "Confirm Update", + "confirm_renew": "Hold to Extend", + "suggestion": "Buy more years now to save on fees", + "set_ens_name": "Set as my primary ENS name", + "registration_duration": "Register name for", + "new_expiration_date": "New expiration date", + "extend_by": "Extend by", + "registration_cost": "Registration cost", + "estimated_fees": "Estimated network fees", + "estimated_total_eth": "Estimated total in ETH", + "estimated_total": "Estimated total", + "duration_plural": "%{content} years", + "duration_singular": "1 year", + "commit_button": "Hold to Commit", + "insufficient_eth": "Insufficient ETH", + "speed_up": "Speed Up", + "extend_registration": "Extend Registration", + "last_step": "One last step", + "last_step_description": "Confirm below to register your name and configure your profile", + "transaction_pending": "Transaction pending", + "transaction_pending_description": "You’ll be taken to the next step automatically when this transaction confirms on the blockchain", + "wait_one_minute": "Wait for one minute", + "wait_one_minute_description": "This waiting period ensures that another person can’t register this name before you do" + }, + "edit": { + "label": "Edit your profile" + }, + "profile_avatar": { + "choose_from_library": "Choose from Library", + "create_profile": "Create your Profile", + "edit_profile": "Edit Profile", + "pick_emoji": "Pick an Emoji", + "remove_photo": "Remove Photo", + "view_profile": "View Profile" + }, + "pending_registrations": { + "alert_title": "Are you sure?", + "alert_message": "`You are about to stop the registration process.\n You'd need to start it again which means you'll need to send an additional transaction.`", + "alert_confirm": "Proceed Anyway", + "alert_cancel": "Cancel", + "in_progress": "In progress", + "finish": "Finish" + } + }, "savings": { "deposit": "Deposit", "deposit_from_wallet": "Deposit from Wallet", diff --git a/src/languages/_french.json b/src/languages/_french.json index 68a1386a8e9..ca93554b84a 100644 --- a/src/languages/_french.json +++ b/src/languages/_french.json @@ -212,7 +212,7 @@ "etherscan": "🔍 Affichage à Etherscan :)", "exchange": "Échanger", "exchange_again": "Échanger encore", - "exchange_search_uniswap": "Cherche", + "exchange_search_placeholder": "Cherche", "go_back": "Retourner", "got_it": "Got it :)", "hide": "Cacher", @@ -263,12 +263,14 @@ "add": "Ajouter le contact", "cancel": "Annuler", "delete": "Effacer le contact", - "edit": "Modifier le contact" + "edit": "Modifier le contact", + "view": "Voir le profil" }, "send_header": "Send :)", "suggestions": "Suggestions :)", "to_header": "To :)", - "watching": "Watching :)" + "watching": "Watching :)", + "input_placeholder": "Nom" }, "developer_settings": { "alert": "Alert :)", @@ -277,6 +279,7 @@ "clear_async_storage": "Clear async storage :)", "clear_mmkv_storage": "Clear MMKV storage :)", "clear_image_metadata_cache": "Clear Image Metadata Cache :)", + "clear_image_cache": "Clear Image Cache :)", "connect_to_hardhat": "Connect to hardhat :)", "crash_app_render_error": "Crash app (render error) :)", "not_applied": "NOT APPLIED :)", @@ -298,7 +301,7 @@ "trading_at_prefix": "Trading at :)" }, "search": { - "ethereum_name_service": "Ethereum Name Service :)", + "profiles": "Profiles :)", "search_ethereum": "Search all of Ethereum :)" }, "strategies": { @@ -412,6 +415,10 @@ "total_value": "Total value :)", "underlying_tokens": "Underlying tokens :)" }, + "nft_brief_token_info": { + "for_sale": "For sale", + "last_sale": "Last sale price" + }, "supported_countries": { "supported_countries": "Supported Countries :)", "us_except": "United States (except Texas and New York) :)" @@ -447,7 +454,21 @@ }, "unique_expanded": { "about": "About %{assetFamilyName} :)", + "advanced": "Advanced :)", "attributes": "Attributes :)", + "configuration": "Configuration :)", + "copy": "Copy :)", + "edit": "Edit :)", + "manager": "Manager :)", + "owner": "Owner :)", + "expires_in": "Expires in :)", + "expires_on": "Expires on :)", + "set_primary_name": "Set as my ENS name :)", + "profile_info": "Profile Info :)", + "registrant": "Registrant :)", + "resolver": "Resolver", + "view_on_opensea": "View on Opensea :)", + "view_on_platform": "View on %{platform} :)", "collection_website": "Collection Website :)", "copy_token_id": "Copy Token ID :)", "description": "Description :)", @@ -531,6 +552,9 @@ "short_placeholder": "ENS or address :)" } }, + "gas": { + "network_fee": "Frais de réseau" + }, "homepage": { "coming_soon": "À venir.", "connect_ledger": { @@ -570,6 +594,12 @@ "private_key": "Clé privée", "recipient_address": "Adresse du destinataire" }, + "image_picker": { + "title": "Rainbow would like to access your photos :)", + "message": "This allows Rainbow to use your photos from your library :)", + "confirm": "Enable library access :)", + "cancel": "Cancel :)" + }, "list": { "nothing_here": "Nothing here! :)", "share": { @@ -703,6 +733,162 @@ "total_value": "Valeur totale", "withdraw": "Retrait" }, + "profiles": { + "actions": { + "watching": "Watching :)", + "watch": "Watch :)", + "unwatch_ens": "Unwatch %{ensName} :)", + "unwatch_ens_title": "Are you sure you want to unwatch %{ensName}? :)", + "edit_profile": "Edit profile :)" + }, + "banner": { + "register_name": "Create Your ENS Profile :)", + "and_create_ens_profile": "Search available .eth names :)" + }, + "details": { + "add_to_contacts": "Add to Contacts :)", + "remove_from_contacts": "Remove from Contacts :)", + "copy_address": "Copy Address :)", + "view_on_etherscan": "View on Etherscan :)" + }, + "intro": { + "create_your": "Create Your :)", + "ens_profile": "ENS Profile :)", + "wallet_address_info": { + "title": "A better wallet address :)", + "description": "Send to ENS names instead of hard-to-remember wallet addresses. :)" + }, + "portable_identity_info": { + "title": "A portable digital identity :)", + "description": "Carry your ENS name and profile between websites. No more signups. :)" + }, + "stored_on_blockchain_info": { + "title": "Stored on Ethereum :)", + "description": "Your profile is stored directly on Ethereum and owned by you. :)" + }, + "find_your_name": "Find your name :)", + "use_name": "Use %{name} :)", + "use_existing_name": "Use an existing ENS name :)", + "search_new_name": "Search for a new ENS name :)", + "choose_another_name": "Choose another ENS name :)", + "my_ens_names": "My ENS Names :)", + "search_new_ens": "Find a New Name :)" + }, + "select_ens_name": "My ENS Names :)", + "search": { + "header": "Find your name :)", + "description": "Search available ENS names :)", + "available": "🥳 Available :)", + "taken": "😭 Taken :)", + "registered_on": "This name was last registered on %{content} :)", + "price": "%{content} / Year :)", + "expiration": "Til %{content} :)", + "3_char_min": "Minimum 3 characters :)", + "estimated_total_cost_1": "Estimated total cost of :)", + "estimated_total_cost_2": "with current network fees :)", + "loading_fees": "Loading network fees… :)", + "clear": "􀅉 Clear :)", + "continue": "Continue 􀆊 :)" + }, + "create": { + "label": "Create your profile :)", + "add_cover": "Add Cover :)", + "name": "Name :)", + "bio": "Bio :)", + "website": "Website :)", + "back": "􀆉 Back :)", + "skip": "Skip :)", + "username_placeholder": "username :)", + "name_placeholder": "Add a display name :)", + "bio_placeholder": "Add a bio to your profile :)", + "website_placeholder": "Add your website :)", + "review": "Review :)", + "cancel": "Cancel :)", + "choose_nft": "Choose an NFT :)", + "upload_photo": "Upload a Photo :)", + "eth": "Ethereum :)", + "btc": "Bitcoin :)", + "ltc": "Litecoin :)", + "doge": "Dogecoin :)", + "wallet_placeholder": "Add a %{coin} address :)", + "content": "Content :)", + "content_placeholder": "Add a content hash :)", + "notice": "Notice :)", + "notice_placeholder": "Add a notice :)", + "keywords": "Keywords :)", + "keywords_placeholder": "Add keywords :)", + "reddit": "Reddit :)", + "twitter": "Twitter :)", + "telegram": "Telegram :)", + "pronouns": "Pronouns :)", + "pronouns_placeholder": "Add pronouns :)", + "email": "Email :)", + "github": "GitHub :)", + "instagram": "Instagram :)", + "snapchat": "Snapchat :)", + "discord": "Discord :)", + "email_placeholder": "Add your email :)", + "email_submit_message": "Please enter a valid email :)", + "website_submit_message": "Please enter a valid email :)", + "invalid_asset": "Invalid %{coin} address :)", + "invalid_content_hash": "Invalid content hash :)", + "remove": "Remove :)", + "email_message": "Please enter a valid email :)", + "website_message": "Please enter a valid website URL :)" + }, + "confirm": { + "confirm_purchase": "Confirm purchase :)", + "registration_details": "Registration Details :)", + "requesting_register": "Requesting to Register :)", + "reserving_name": "Reserving Your Name :)", + "confirm_registration": "Confirm Registration :)", + "set_name_registration": "Set as Primary Name :)", + "confirm_set_name": "Confirm Set Primary Name :)", + "start_registration": "Start Registration :)", + "confirm_update": "Confirm Update :)", + "confirm_renew": "Hold to Extend :)", + "suggestion": "Buy more years now to save on fees :)", + "set_ens_name": "Set as my primary ENS name :)", + "registration_duration": "Register name for :)", + "new_expiration_date": "New expiration date :)", + "extend_by": "Extend by :)", + "registration_cost": "Registration cost :)", + "estimated_fees": "Estimated network fees :)", + "estimated_total_eth": "Estimated total in ETH :)", + "estimated_total": "Estimated total :)", + "duration_plural": "%{content} years :)", + "duration_singular": "1 year :)", + "commit_button": "Hold to Commit :)", + "insufficient_eth": "Insufficient ETH :)", + "speed_up": "Speed Up :)", + "extend_registration": "Extend Registration :)", + "last_step": "One last step :)", + "last_step_description": "Confirm below to register your name and configure your profile :)", + "transaction_pending": "Transaction pending :)", + "transaction_pending_description": "You’ll be taken to the next step automatically when this transaction confirms on the blockchain :)", + "wait_one_minute": "Wait for one minute :)", + "wait_one_minute_description": "This waiting period ensures that another person can’t register this name before you do :)" + }, + "edit": { + "label": "Edit your profile :)" + }, + "profile_avatar": { + "choose_from_library": "Choose from Library :)", + "create_profile": "Create your Profile :)", + "edit_profile": "Edit Profile :)", + "pick_emoji": "Pick an Emoji :)", + "remove_photo": "Remove Photo :)", + "view_profile": "View Profile :)" + }, + "pending_registrations": { + "alert_title": "Are you sure? :)", + "alert_message": "`You are about to stop the registration process.\n You'd need to start it again which means you'll need to send an additional transaction.` :)", + "alert_confirm": "Proceed Anyway :)", + "alert_cancel": "Cancel :)", + "in_progress": "In progress :)", + "finish": "Finish :)" + } + }, "savings": { "deposit": "Dépôt", "deposit_from_wallet": "Deposit from Wallet :)", diff --git a/src/model/wallet.ts b/src/model/wallet.ts index 9c72cb6a9a3..f4078a9c95f 100644 --- a/src/model/wallet.ts +++ b/src/model/wallet.ts @@ -194,7 +194,10 @@ export const walletInit = async ( name = null, overwrite = false, checkedWallet = null, - network: string + network: string, + image = null, + // Import the wallet "silently" in the background (i.e. no "loading" prompts). + silent = false ): Promise => { let walletAddress = null; @@ -209,7 +212,9 @@ export const walletInit = async ( color, name, overwrite, - checkedWallet + checkedWallet, + image, + silent ); walletAddress = wallet?.address; return { isNew, walletAddress }; @@ -538,7 +543,9 @@ export const createWallet = async ( color: null | number = null, name: null | string = null, overwrite: boolean = false, - checkedWallet: null | EthereumWalletFromSeed = null + checkedWallet: null | EthereumWalletFromSeed = null, + image: null | string = null, + silent: boolean = false ): Promise => { const isImported = !!seed; logger.sentry('Creating wallet, isImported?', isImported); @@ -549,7 +556,10 @@ export const createWallet = async ( let addresses: RainbowAccount[] = []; try { const { dispatch } = store; - dispatch(setIsWalletLoading(WalletLoadingStates.CREATING_WALLET)); + + if (!silent) { + dispatch(setIsWalletLoading(WalletLoadingStates.CREATING_WALLET)); + } const { isHDWallet, @@ -635,7 +645,9 @@ export const createWallet = async ( dispatch( setIsWalletLoading( seed - ? WalletLoadingStates.IMPORTING_WALLET + ? silent + ? WalletLoadingStates.IMPORTING_WALLET_SILENTLY + : WalletLoadingStates.IMPORTING_WALLET : WalletLoadingStates.CREATING_WALLET ) ); @@ -687,7 +699,7 @@ export const createWallet = async ( address: walletAddress, avatar: null, color: colorIndexForWallet, - image: null, + image, index: 0, label: name || '', visible: true, @@ -854,8 +866,10 @@ export const createWallet = async ( type, }; - await setSelectedWallet(allWallets[id]); - logger.sentry('[createWallet] - setSelectedWallet'); + if (!silent) { + await setSelectedWallet(allWallets[id]); + logger.sentry('[createWallet] - setSelectedWallet'); + } await saveAllWallets(allWallets); logger.sentry('[createWallet] - saveAllWallets'); diff --git a/src/navigation/RegisterENSNavigator.js b/src/navigation/RegisterENSNavigator.js deleted file mode 100644 index 4b753c8bff2..00000000000 --- a/src/navigation/RegisterENSNavigator.js +++ /dev/null @@ -1,137 +0,0 @@ -import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; -import ConditionalWrap from 'conditional-wrap'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { StatusBar } from 'react-native'; -import { useRecoilState } from 'recoil'; -import { SheetHandleFixedToTopHeight, SlackSheet } from '../components/sheet'; -import ENSAssignRecordsSheet, { - ENSAssignRecordsBottomActions, -} from '../screens/ENSAssignRecordsSheet'; -import ENSSearchSheet from '../screens/ENSSearchSheet'; -import ScrollPagerWrapper from './ScrollPagerWrapper'; -import { sharedCoolModalTopOffset } from './config'; -import { Box } from '@rainbow-me/design-system'; -import { accentColorAtom } from '@rainbow-me/helpers/ens'; -import { useDimensions } from '@rainbow-me/hooks'; -import Routes from '@rainbow-me/routes'; -import { deviceUtils } from '@rainbow-me/utils'; - -const Swipe = createMaterialTopTabNavigator(); - -const renderTabBar = () => null; -const renderPager = props => ( - -); - -const defaultScreenOptions = { - [Routes.ENS_ASSIGN_RECORDS_SHEET]: { - scrollEnabled: true, - useAccentAsSheetBackground: true, - }, - [Routes.ENS_SEARCH_SHEET]: { - scrollEnabled: false, - useAccentAsSheetBackground: false, - }, -}; - -const initialRouteName = Routes.ENS_SEARCH_SHEET; - -export default function RegisterENSNavigator() { - const sheetRef = useRef(); - - const { height: deviceHeight } = useDimensions(); - - const contentHeight = - deviceHeight - SheetHandleFixedToTopHeight - sharedCoolModalTopOffset; - - const [currentRouteName, setCurrentRouteName] = useState(initialRouteName); - - const screenOptions = useMemo(() => defaultScreenOptions[currentRouteName], [ - currentRouteName, - ]); - - const [accentColor] = useRecoilState(accentColorAtom); - - const [scrollEnabled, setScrollEnabled] = useState( - screenOptions.scrollEnabled - ); - useEffect(() => { - // Wait 200ms to prevent transition lag - setTimeout(() => { - setScrollEnabled(screenOptions.scrollEnabled); - }, 200); - }, [screenOptions.scrollEnabled]); - - useEffect(() => { - StatusBar.setBarStyle('light-content'); - }, []); - - useEffect(() => { - if (!screenOptions.scrollEnabled) { - sheetRef.current.scrollTo({ animated: false, x: 0, y: 0 }); - } - }, [screenOptions.scrollEnabled]); - - const isBottomActionsVisible = - currentRouteName === Routes.ENS_ASSIGN_RECORDS_SHEET; - - const wrapperStyle = useMemo(() => ({ height: contentHeight }), [ - contentHeight, - ]); - - return ( - <> - - {children}} - > - - setCurrentRouteName(Routes.ENS_SEARCH_SHEET), - }} - name={Routes.ENS_SEARCH_SHEET} - /> - - setCurrentRouteName(Routes.ENS_ASSIGN_RECORDS_SHEET), - }} - name={Routes.ENS_ASSIGN_RECORDS_SHEET} - /> - - - - - {/** - * The `ENSAssignRecordsBottomActions` is a component that is external from the ENS navigator and only - * appears when the ENSAssignRecordsSheet is active. - * The reason why is because we can't achieve fixed positioning (as per designs) within SlackSheet's - * ScrollView, so this seems like the best workaround. - */} - - - ); -} diff --git a/src/navigation/RegisterENSNavigator.tsx b/src/navigation/RegisterENSNavigator.tsx new file mode 100644 index 00000000000..9cd5e2b5d95 --- /dev/null +++ b/src/navigation/RegisterENSNavigator.tsx @@ -0,0 +1,225 @@ +import { useRoute } from '@react-navigation/core'; +import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Dimensions, StatusBar } from 'react-native'; +import { useSetRecoilState } from 'recoil'; +import { SheetHandleFixedToTopHeight, SlackSheet } from '../components/sheet'; +import ENSAssignRecordsSheet, { + ENSAssignRecordsBottomActions, +} from '../screens/ENSAssignRecordsSheet'; +import ENSIntroSheet from '../screens/ENSIntroSheet'; +import ENSSearchSheet from '../screens/ENSSearchSheet'; +import ScrollPagerWrapper from './ScrollPagerWrapper'; +import { sharedCoolModalTopOffset } from './config'; +import { useTheme } from '@rainbow-me/context'; +import { Box } from '@rainbow-me/design-system'; +import { accentColorAtom, REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; +import { + useDimensions, + useENSRegistration, + useENSRegistrationForm, + usePrevious, +} from '@rainbow-me/hooks'; +import Routes from '@rainbow-me/routes'; +import { deviceUtils } from '@rainbow-me/utils'; + +const Swipe = createMaterialTopTabNavigator(); + +const renderTabBar = () => null; +const renderPager = (props: any) => ( + +); + +const defaultScreenOptions = { + [Routes.ENS_ASSIGN_RECORDS_SHEET]: { + scrollEnabled: true, + useAccentAsSheetBackground: true, + }, + [Routes.ENS_INTRO_SHEET]: { + scrollEnabled: false, + useAccentAsSheetBackground: false, + }, + [Routes.ENS_SEARCH_SHEET]: { + scrollEnabled: true, + useAccentAsSheetBackground: false, + }, +}; + +export default function RegisterENSNavigator() { + const { params } = useRoute(); + + const sheetRef = useRef(); + + const { height: deviceHeight, isSmallPhone } = useDimensions(); + + const setAccentColor = useSetRecoilState(accentColorAtom); + + const { colors } = useTheme(); + + const contentHeight = + deviceHeight - + SheetHandleFixedToTopHeight - + (!isSmallPhone ? sharedCoolModalTopOffset : 0); + + const [isSearchEnabled, setIsSearchEnabled] = useState(true); + + const { clearValues } = useENSRegistrationForm(); + + const { + removeRecordByKey, + clearCurrentRegistrationName, + startRegistration, + } = useENSRegistration(); + + const initialRouteName = useMemo(() => { + const { ensName, mode } = params || { mode: REGISTRATION_MODES.CREATE }; + if (mode === REGISTRATION_MODES.EDIT) { + startRegistration(ensName, REGISTRATION_MODES.EDIT); + return Routes.ENS_ASSIGN_RECORDS_SHEET; + } + if (mode === REGISTRATION_MODES.SET_NAME) { + startRegistration(ensName, REGISTRATION_MODES.SET_NAME); + return Routes.ENS_CONFIRM_REGISTER_SHEET; + } + return Routes.ENS_INTRO_SHEET; + }, [params, startRegistration]); + const [currentRouteName, setCurrentRouteName] = useState(initialRouteName); + const previousRouteName = usePrevious(currentRouteName); + + const [wrapperHeight, setWrapperHeight] = useState( + contentHeight + ); + + const screenOptions = useMemo(() => defaultScreenOptions[currentRouteName], [ + currentRouteName, + ]); + + useEffect( + () => () => { + clearCurrentRegistrationName(); + }, + [clearCurrentRegistrationName] + ); + + useEffect( + () => () => { + removeRecordByKey('avatar'); + setAccentColor(colors.purple); + clearValues(); + clearCurrentRegistrationName(); + }, + [ + clearCurrentRegistrationName, + clearValues, + colors.purple, + removeRecordByKey, + setAccentColor, + ] + ); + + const enableAssignRecordsBottomActions = + currentRouteName !== Routes.ENS_INTRO_SHEET; + const isBottomActionsVisible = + currentRouteName === Routes.ENS_ASSIGN_RECORDS_SHEET; + + useEffect(() => { + if (screenOptions.scrollEnabled) { + setTimeout(() => setWrapperHeight(undefined), 200); + return; + } + setWrapperHeight(contentHeight); + }, [contentHeight, screenOptions.scrollEnabled]); + + useEffect(() => { + if (!screenOptions.scrollEnabled) { + sheetRef.current?.scrollTo({ animated: false, x: 0, y: 0 }); + } + }, [screenOptions.scrollEnabled]); + + return ( + <> + {/* @ts-expect-error JavaScript component */} + + + + + setIsSearchEnabled(true), + onSelectExistingName: () => setIsSearchEnabled(false), + }} + listeners={{ + focus: () => { + setCurrentRouteName(Routes.ENS_INTRO_SHEET); + }, + }} + name={Routes.ENS_INTRO_SHEET} + /> + {isSearchEnabled && ( + setCurrentRouteName(Routes.ENS_SEARCH_SHEET), + }} + name={Routes.ENS_SEARCH_SHEET} + /> + )} + { + setCurrentRouteName(Routes.ENS_ASSIGN_RECORDS_SHEET); + }, + }} + name={Routes.ENS_ASSIGN_RECORDS_SHEET} + /> + + + + + {/** + * The `ENSAssignRecordsBottomActions` is a component that is external from the ENS navigator and only + * appears when the ENSAssignRecordsSheet is active. + * The reason why is because we can't achieve fixed positioning (as per designs) within SlackSheet's + * ScrollView, so this seems like the best workaround. + */} + {enableAssignRecordsBottomActions && ( + + )} + + ); +} diff --git a/src/navigation/Routes.android.js b/src/navigation/Routes.android.js index c51541ca9e5..4350852e7c8 100644 --- a/src/navigation/Routes.android.js +++ b/src/navigation/Routes.android.js @@ -8,6 +8,7 @@ import BackupSheet from '../screens/BackupSheet'; import ChangeWalletSheet from '../screens/ChangeWalletSheet'; import ConnectedDappsSheet from '../screens/ConnectedDappsSheet'; import DepositModal from '../screens/DepositModal'; +import ENSAdditionalRecordsSheet from '../screens/ENSAdditionalRecordsSheet'; import ENSConfirmRegisterSheet from '../screens/ENSConfirmRegisterSheet'; import ExpandedAssetSheet from '../screens/ExpandedAssetSheet'; import ExplainSheet from '../screens/ExplainSheet'; @@ -15,9 +16,12 @@ import ExternalLinkWarningSheet from '../screens/ExternalLinkWarningSheet'; import ImportSeedPhraseSheet from '../screens/ImportSeedPhraseSheet'; import ModalScreen from '../screens/ModalScreen'; import PinAuthenticationScreen from '../screens/PinAuthenticationScreen'; +import ProfileSheet from '../screens/ProfileSheet'; import ReceiveModal from '../screens/ReceiveModal'; import RestoreSheet from '../screens/RestoreSheet'; import SavingsSheet from '../screens/SavingsSheet'; +import SelectENSSheet from '../screens/SelectENSSheet'; +import SelectUniqueTokenSheet from '../screens/SelectUniqueTokenSheet'; import SendConfirmationSheet from '../screens/SendConfirmationSheet'; import SendSheet from '../screens/SendSheet'; import SettingsModal from '../screens/SettingsModal'; @@ -45,6 +49,7 @@ import { androidRecievePreset, bottomSheetPreset, emojiPreset, + ensPreset, exchangePreset, expandedPreset, expandedPresetWithSmallGestureResponseDistance, @@ -119,6 +124,7 @@ function AddCashFlowNavigator() { function MainNavigator() { const initialRoute = useContext(InitialRouteContext); + const profilesEnabled = useExperimentalFlag(PROFILES); return ( + {profilesEnabled && ( + <> + + + + + + + + )} - {profilesEnabled && ( - <> - - - - )} + + + + + + )} {isNativeStackAvailable ? ( diff --git a/src/navigation/config.js b/src/navigation/config.js index e68af76d5c4..8795ca27261 100644 --- a/src/navigation/config.js +++ b/src/navigation/config.js @@ -6,6 +6,7 @@ import { SheetHandleFixedToTopHeight } from '../components/sheet'; import { Text } from '../components/text'; import { useTheme } from '../context/ThemeContext'; import colors from '../context/currentColors'; +import { getENSAdditionalRecordsSheetHeight } from '../screens/ENSAdditionalRecordsSheet'; import { ENSConfirmRegisterSheetHeight } from '../screens/ENSConfirmRegisterSheet'; import { explainers, ExplainSheetHeight } from '../screens/ExplainSheet'; import { ExternalLinkWarningSheetHeight } from '../screens/ExternalLinkWarningSheet'; @@ -141,15 +142,53 @@ export const registerENSNavigatorConfig = { backgroundOpacity: 1, scrollEnabled: true, springDamping: 1, + transitionDuration: 0.3, }), }), }; -export const ensConfirmRegisterSheetConfig = { +export const profileConfig = { + options: ({ route: { params = {} } }) => ({ + ...buildCoolModalConfig({ + ...params, + backgroundOpacity: 1, + scrollEnabled: true, + springDamping: 1, + transitionDuration: 0.3, + }), + }), +}; + +export const profilePreviewConfig = { options: ({ route: { params = {} } }) => ({ ...buildCoolModalConfig({ ...params, + backgroundOpacity: 0, + disableShortFormAfterTransitionToLongForm: true, + isShortFormEnabled: true, + scrollEnabled: true, + shortFormHeight: 281 + params.descriptionProfilePreviewHeight, + springDamping: 1, + startFromShortForm: true, + transitionDuration: 0.3, + }), + }), +}; + +export const ensConfirmRegisterSheetConfig = { + options: ({ route: { params = {} } }) => ({ + ...buildCoolModalConfig({ longFormHeight: ENSConfirmRegisterSheetHeight, + ...params, + }), + }), +}; + +export const ensAdditionalRecordsSheetConfig = { + options: ({ route: { params = {} } }) => ({ + ...buildCoolModalConfig({ + ...params, + longFormHeight: getENSAdditionalRecordsSheetHeight(), }), }), }; diff --git a/src/navigation/effects.js b/src/navigation/effects.js index 6c3d52833d2..9bd179a1365 100644 --- a/src/navigation/effects.js +++ b/src/navigation/effects.js @@ -337,6 +337,18 @@ export const exchangePreset = { transitionSpec: { close: closeSpec, open: sheetOpenSpec }, }; +export const ensPreset = { + cardOverlayEnabled: true, + cardShadowEnabled: true, + cardStyle: { backgroundColor: 'transparent' }, + cardStyleInterpolator: speedUpAndCancelStyleInterpolator, + cardTransparent: true, + gestureDirection: 'vertical', + gestureEnabled: true, + gestureResponseDistance, + transitionSpec: { close: closeSpec, open: sheetOpenSpec }, +}; + export const androidRecievePreset = { cardStyle: { backgroundColor: 'transparent' }, cardStyleInterpolator: expandStyleInterpolator(0.9), diff --git a/src/navigation/onNavigationStateChange.js b/src/navigation/onNavigationStateChange.js index dbbed089191..577a4b7b355 100644 --- a/src/navigation/onNavigationStateChange.js +++ b/src/navigation/onNavigationStateChange.js @@ -121,6 +121,7 @@ export function onNavigationStateChange(currentState) { routeName === Routes.SWAP_DETAILS_SHEET || routeName === Routes.QR_SCANNER_SCREEN || routeName === Routes.CUSTOM_GAS_SHEET || + routeName === Routes.ENS_INTRO_SHEET || routeName === Routes.ENS_SEARCH_SHEET || routeName === Routes.ENS_ASSIGN_RECORDS_SHEET || (routeName === Routes.MODAL_SCREEN && diff --git a/src/navigation/routesNames.js b/src/navigation/routesNames.js index 131b8e6f32d..c715a8124ba 100644 --- a/src/navigation/routesNames.js +++ b/src/navigation/routesNames.js @@ -13,8 +13,10 @@ const Routes = { CONNECTED_DAPPS: 'ConnectedDapps', CURRENCY_SELECT_SCREEN: 'CurrencySelectScreen', CUSTOM_GAS_SHEET: 'CustomGasSheet', + ENS_ADDITIONAL_RECORDS_SHEET: 'ENSAdditionalRecordsSheet', ENS_ASSIGN_RECORDS_SHEET: 'ENSAssignRecordsSheet', ENS_CONFIRM_REGISTER_SHEET: 'ENSConfirmRegisterSheet', + ENS_INTRO_SHEET: 'ENSIntroSheet', ENS_SEARCH_SHEET: 'ENSSearchSheet', EXAMPLE_SCREEN: 'ExampleScreen', EXCHANGE_MODAL: 'ExchangeModal', @@ -34,7 +36,9 @@ const Routes = { MODAL_SCREEN: 'ModalScreen', NATIVE_STACK: 'NativeStack', PIN_AUTHENTICATION_SCREEN: 'PinAuthenticationScreen', + PROFILE_PREVIEW_SHEET: 'ProfilePreviewSheet', PROFILE_SCREEN: 'ProfileScreen', + PROFILE_SHEET: 'ProfileSheet', QR_SCANNER_SCREEN: 'QRScannerScreen', RECEIVE_MODAL: 'ReceiveModal', REGISTER_ENS_NAVIGATOR: 'RegisterEnsNavigator', @@ -42,6 +46,8 @@ const Routes = { SAVINGS_DEPOSIT_MODAL: 'SavingsDepositModal', SAVINGS_SHEET: 'SavingsSheet', SAVINGS_WITHDRAW_MODAL: 'SavingsWithdrawModal', + SELECT_ENS_SHEET: 'SelectENSSheet', + SELECT_UNIQUE_TOKEN_SHEET: 'SelectUniqueTokenSheet', SEND_CONFIRMATION_SHEET: 'SendConfirmationSheet', SEND_SHEET: 'SendSheet', SEND_SHEET_NAVIGATOR: 'SendSheetNavigator', diff --git a/src/parsers/index.ts b/src/parsers/index.ts index 426bb76b20d..30b912f8253 100644 --- a/src/parsers/index.ts +++ b/src/parsers/index.ts @@ -32,6 +32,7 @@ export { export { parseAccountUniqueTokens, parseAccountUniqueTokensPolygon, + handleAndSignImages, getFamilies, dedupeUniqueTokens, dedupeAssetsWithFamilies, diff --git a/src/parsers/poap.js b/src/parsers/poap.js index 909d3826c37..5709ba93b42 100644 --- a/src/parsers/poap.js +++ b/src/parsers/poap.js @@ -29,7 +29,7 @@ export const parsePoaps = data => { name: 'POAP', short_description: 'The Proof of Attendance Protocol', }, - description: event.description, + description: event?.description, external_link: event.event_url, familyImage: 'https://lh3.googleusercontent.com/FwLriCvKAMBBFHMxcjqvxjTlmROcDIabIFKRp87NS3u_QfSLxcNThgAzOJSbphgQqnyZ_v2fNgMZQkdCYHUliJwH-Q=s60', diff --git a/src/parsers/uniqueTokens.js b/src/parsers/uniqueTokens.js index 3e216f85eff..35061de7775 100644 --- a/src/parsers/uniqueTokens.js +++ b/src/parsers/uniqueTokens.js @@ -11,9 +11,9 @@ import { } from 'lodash'; import { CardSize } from '../components/unique-token/CardSize'; import { AssetTypes } from '@rainbow-me/entities'; +import { fetchMetadata, isUnknownOpenSeaENS } from '@rainbow-me/handlers/ens'; import { maybeSignUri } from '@rainbow-me/handlers/imgix'; import svgToPngIfNeeded from '@rainbow-me/handlers/svgs'; -import isSupportedUriExtension from '@rainbow-me/helpers/isSupportedUriExtension'; import { Network } from '@rainbow-me/helpers/networkTypes'; import { ENS_NFT_CONTRACT_ADDRESS, @@ -21,6 +21,7 @@ import { } from '@rainbow-me/references'; import { getFullSizeUrl } from '@rainbow-me/utils/getFullSizeUrl'; import { getLowResUrl } from '@rainbow-me/utils/getLowResUrl'; +import isSVGImage from '@rainbow-me/utils/isSVG'; const parseLastSalePrice = lastSale => lastSale @@ -35,24 +36,17 @@ const parseLastSalePrice = lastSale => * @return {Object} */ -const handleAndSignImages = ( - contractAddress, - imageUrl, - previewUrl, - originalUrl -) => { +export const handleAndSignImages = (imageUrl, previewUrl, originalUrl) => { + if (!imageUrl && !previewUrl && !originalUrl) { + return { imageUrl: undefined, lowResUrl: undefined }; + } + const lowResImageOptions = { w: CardSize, }; - const isSVG = isSupportedUriExtension(imageUrl, ['.svg']); - + const isSVG = isSVGImage(imageUrl); const image = imageUrl || originalUrl || previewUrl; - const isENS = toLower(contractAddress) === toLower(ENS_NFT_CONTRACT_ADDRESS); - const fullImage = isENS - ? maybeSignUri(svgToPngIfNeeded(image, true)) - : isSVG - ? image - : getFullSizeUrl(image); + const fullImage = isSVG ? image : getFullSizeUrl(image); const lowResUrl = isSVG ? maybeSignUri(svgToPngIfNeeded(image), lowResImageOptions) @@ -83,7 +77,6 @@ export const parseAccountUniqueTokens = data => { ...asset }) => { const { imageUrl, lowResUrl } = handleAndSignImages( - asset_contract.address, asset.image_url, asset.image_original_url, asset.image_preview_url @@ -161,13 +154,12 @@ export const parseAccountUniqueTokens = data => { .filter(token => !!token.familyName); }; -export const parseAccountUniqueTokensPolygon = async data => { +export const parseAccountUniqueTokensPolygon = data => { let erc721s = data?.data?.results; if (isNil(erc721s)) throw new Error('Invalid data from OpenSea Polygon'); erc721s = erc721s .map(({ asset_contract, collection, token_id, metadata, ...asset }) => { const { imageUrl, lowResUrl } = handleAndSignImages( - asset_contract.address, asset.image_url, asset.image_original_url, asset.image_preview_url @@ -245,6 +237,39 @@ export const parseAccountUniqueTokensPolygon = async data => { return erc721s; }; +export const applyENSMetadataFallbackToToken = async token => { + const isENS = + token?.asset_contract?.address?.toLowerCase() === + ENS_NFT_CONTRACT_ADDRESS.toLowerCase(); + if (isENS && isUnknownOpenSeaENS(token)) { + const { name, image_url } = await fetchMetadata({ + tokenId: token.id, + }); + const { imageUrl, lowResUrl } = handleAndSignImages(image_url); + return { + ...token, + image_preview_url: lowResUrl, + image_url: imageUrl, + lowResUrl, + name, + uniqueId: name, + }; + } + return token; +}; + +export const applyENSMetadataFallbackToTokens = async data => { + return await Promise.all( + data.map(async token => { + try { + return applyENSMetadataFallbackToToken(token); + } catch { + return token; + } + }) + ); +}; + export const getFamilies = uniqueTokens => uniq(map(uniqueTokens, u => get(u, 'asset_contract.address', ''))); diff --git a/src/raps/actions/depositCompound.ts b/src/raps/actions/depositCompound.ts index 6c221bd2a7b..debb0e25f76 100644 --- a/src/raps/actions/depositCompound.ts +++ b/src/raps/actions/depositCompound.ts @@ -1,7 +1,11 @@ import { Contract } from '@ethersproject/contracts'; import { Wallet } from '@ethersproject/wallet'; import { captureException } from '@sentry/react-native'; -import { Rap, RapActionParameters, SwapActionParameters } from '../common'; +import { + Rap, + RapExchangeActionParameters, + SwapActionParameters, +} from '../common'; import { Asset, ProtocolType, @@ -33,7 +37,7 @@ const depositCompound = async ( wallet: Wallet, currentRap: Rap, index: number, - parameters: RapActionParameters, + parameters: RapExchangeActionParameters, baseNonce?: number ): Promise => { logger.log(`[${actionName}] base nonce`, baseNonce, 'index:', index); diff --git a/src/raps/actions/ens.ts b/src/raps/actions/ens.ts index 4d62165ab85..bbb2a542d00 100644 --- a/src/raps/actions/ens.ts +++ b/src/raps/actions/ens.ts @@ -1,19 +1,30 @@ import { Wallet } from '@ethersproject/wallet'; +import analytics from '@segment/analytics-react-native'; import { captureException } from '@sentry/react-native'; -import { Rap, RapActionParameters, RapActionTypes } from '../common'; -import { estimateENSTransactionGasLimit } from '@rainbow-me/handlers/ens'; +import { + // @ts-ignore + IS_TESTING, +} from 'react-native-dotenv'; +import { Rap, RapActionTypes, RapENSActionParameters } from '../common'; +import { ENSRegistrationRecords } from '@rainbow-me/entities'; +import { + estimateENSTransactionGasLimit, + formatRecordsForTransaction, +} from '@rainbow-me/handlers/ens'; import { toHex } from '@rainbow-me/handlers/web3'; import { NetworkTypes } from '@rainbow-me/helpers'; import { - ENSRegistrationRecords, ENSRegistrationTransactionType, getENSExecutionDetails, } from '@rainbow-me/helpers/ens'; import { dataAddNewTransaction } from '@rainbow-me/redux/data'; +import { + saveCommitRegistrationParameters, + updateTransactionRegistrationParameters, +} from '@rainbow-me/redux/ensRegistration'; import store from '@rainbow-me/redux/store'; import { ethereumUtils } from '@rainbow-me/utils'; import logger from 'logger'; - const executeCommit = async ( name?: string, duration?: number, @@ -100,10 +111,43 @@ const executeMulticall = async ( type: ENSRegistrationTransactionType.MULTICALL, wallet, }); + return ( + methodArguments && + contract?.multicall(...methodArguments, { + gasLimit: gasLimit ? toHex(gasLimit) : undefined, + maxFeePerGas: maxFeePerGas ? toHex(maxFeePerGas) : undefined, + maxPriorityFeePerGas: maxPriorityFeePerGas + ? toHex(maxPriorityFeePerGas) + : undefined, + nonce: nonce ? toHex(nonce) : undefined, + ...(value ? { value } : {}), + }) + ); +}; + +const executeRenew = async ( + name?: string, + duration?: number, + ownerAddress?: string, + rentPrice?: string, + gasLimit?: string | null, + maxFeePerGas?: string, + maxPriorityFeePerGas?: string, + wallet?: Wallet, + nonce: number | null = null +) => { + const { contract, methodArguments, value } = await getENSExecutionDetails({ + duration, + name, + ownerAddress, + rentPrice, + type: ENSRegistrationTransactionType.RENEW, + wallet, + }); return ( methodArguments && - contract?.setText(...methodArguments, { + contract?.renew(...methodArguments, { gasLimit: gasLimit ? toHex(gasLimit) : undefined, maxFeePerGas: maxFeePerGas ? toHex(maxFeePerGas) : undefined, maxPriorityFeePerGas: maxPriorityFeePerGas @@ -133,7 +177,7 @@ const executeSetName = async ( return ( methodArguments && - contract?.setText(...methodArguments, { + contract?.setName(...methodArguments, { gasLimit: gasLimit ? toHex(gasLimit) : undefined, maxFeePerGas: maxFeePerGas ? toHex(maxFeePerGas) : undefined, maxPriorityFeePerGas: maxPriorityFeePerGas @@ -147,8 +191,7 @@ const executeSetName = async ( const executeSetText = async ( name?: string, - recordKey?: string, - recordValue?: string, + records?: ENSRegistrationRecords, gasLimit?: string | null, maxFeePerGas?: string, maxPriorityFeePerGas?: string, @@ -157,17 +200,7 @@ const executeSetText = async ( ) => { const { contract, methodArguments, value } = await getENSExecutionDetails({ name, - records: { - coinAddress: null, - contentHash: null, - ensAssociatedAddress: null, - text: [ - { - key: recordKey || '', - value: recordValue || '', - }, - ], - }, + records, type: ENSRegistrationTransactionType.SET_TEXT, wallet, }); @@ -190,7 +223,7 @@ const ensAction = async ( wallet: Wallet, actionName: string, index: number, - parameters: RapActionParameters, + parameters: RapENSActionParameters, type: ENSRegistrationTransactionType, baseNonce?: number ): Promise => { @@ -198,19 +231,12 @@ const ensAction = async ( const { dispatch } = store; const { accountAddress: ownerAddress } = store.getState().settings; const { selectedGasFee } = store.getState().gas; - const { - name, - duration, - rentPrice, - records, - recordKey, - recordValue, - salt, - } = parameters; + const { name, duration, rentPrice, records, salt } = parameters; logger.log(`[${actionName}] rap for`, name); let gasLimit; + const ensRegistrationRecords = formatRecordsForTransaction(records); try { logger.sentry( `[${actionName}] estimate gas`, @@ -219,11 +245,17 @@ const ensAction = async ( }, type ); + + const setRecordsType = + IS_TESTING !== 'true' && + (type === ENSRegistrationTransactionType.MULTICALL || + type === ENSRegistrationTransactionType.SET_TEXT); + gasLimit = await estimateENSTransactionGasLimit({ duration, name, - ownerAddress, - records, + ownerAddress: setRecordsType ? undefined : ownerAddress, + records: ensRegistrationRecords, rentPrice, salt, type, @@ -260,6 +292,35 @@ const ensAction = async ( wallet, nonce ); + dispatch( + // @ts-ignore + saveCommitRegistrationParameters({ + commitTransactionHash: tx?.hash, + duration, + name, + ownerAddress, + records, + rentPrice, + salt, + }) + ); + analytics.track('Initiated ENS registration', { + category: 'profiles', + }); + break; + case ENSRegistrationTransactionType.MULTICALL: + tx = await executeMulticall( + name, + ensRegistrationRecords, + gasLimit, + maxFeePerGas, + maxPriorityFeePerGas, + wallet, + nonce + ); + analytics.track('Edited ENS records', { + category: 'profiles', + }); break; case ENSRegistrationTransactionType.REGISTER_WITH_CONFIG: tx = await executeRegisterWithConfig( @@ -274,29 +335,45 @@ const ensAction = async ( wallet, nonce ); + dispatch( + // @ts-ignore + updateTransactionRegistrationParameters({ + registerTransactionHash: tx?.hash, + }) + ); + analytics.track('Completed ENS registration', { + category: 'profiles', + }); break; - case ENSRegistrationTransactionType.SET_TEXT: - tx = await executeSetText( + case ENSRegistrationTransactionType.RENEW: + tx = await executeRenew( name, - recordKey, - recordValue, + duration, + ownerAddress, + rentPrice, gasLimit, maxFeePerGas, maxPriorityFeePerGas, wallet, nonce ); + analytics.track('Extended ENS', { + category: 'profiles', + }); break; - case ENSRegistrationTransactionType.MULTICALL: - tx = await executeMulticall( + case ENSRegistrationTransactionType.SET_TEXT: + tx = await executeSetText( name, - records, + ensRegistrationRecords, gasLimit, maxFeePerGas, maxPriorityFeePerGas, wallet, nonce ); + analytics.track('Edited ENS records', { + category: 'profiles', + }); break; case ENSRegistrationTransactionType.SET_NAME: tx = await executeSetName( @@ -308,6 +385,9 @@ const ensAction = async ( wallet, nonce ); + analytics.track('Set ENS to primary ', { + category: 'profiles', + }); } } catch (e) { logger.sentry(`[${actionName}] Error executing`); @@ -337,7 +417,7 @@ const ensAction = async ( // @ts-expect-error Since src/redux/data.js is not typed yet, `accountAddress` // being a string conflicts with the inferred type of "null" for the second // parameter. - await dispatch(dataAddNewTransaction(newTransaction, ownerAddress, true)); + await dispatch(dataAddNewTransaction(newTransaction, ownerAddress)); return tx?.nonce; }; @@ -345,7 +425,7 @@ const commitENS = async ( wallet: Wallet, currentRap: Rap, index: number, - parameters: RapActionParameters, + parameters: RapENSActionParameters, baseNonce?: number ): Promise => { return ensAction( @@ -358,53 +438,53 @@ const commitENS = async ( ); }; -const registerENS = async ( +const multicallENS = async ( wallet: Wallet, currentRap: Rap, index: number, - parameters: RapActionParameters, + parameters: RapENSActionParameters, baseNonce?: number ): Promise => { return ensAction( wallet, - RapActionTypes.registerENS, + RapActionTypes.multicallENS, index, parameters, - ENSRegistrationTransactionType.REGISTER_WITH_CONFIG, + ENSRegistrationTransactionType.MULTICALL, baseNonce ); }; -const multicallENS = async ( +const registerWithConfig = async ( wallet: Wallet, currentRap: Rap, index: number, - parameters: RapActionParameters, + parameters: RapENSActionParameters, baseNonce?: number ): Promise => { return ensAction( wallet, - RapActionTypes.multicallENS, + RapActionTypes.registerWithConfigENS, index, parameters, - ENSRegistrationTransactionType.MULTICALL, + ENSRegistrationTransactionType.REGISTER_WITH_CONFIG, baseNonce ); }; -const setTextENS = async ( +const renewENS = async ( wallet: Wallet, currentRap: Rap, index: number, - parameters: RapActionParameters, + parameters: RapENSActionParameters, baseNonce?: number ): Promise => { return ensAction( wallet, - RapActionTypes.setTextENS, + RapActionTypes.renewENS, index, parameters, - ENSRegistrationTransactionType.SET_TEXT, + ENSRegistrationTransactionType.RENEW, baseNonce ); }; @@ -413,7 +493,7 @@ const setNameENS = async ( wallet: Wallet, currentRap: Rap, index: number, - parameters: RapActionParameters, + parameters: RapENSActionParameters, baseNonce?: number ): Promise => { return ensAction( @@ -426,10 +506,28 @@ const setNameENS = async ( ); }; +const setTextENS = async ( + wallet: Wallet, + currentRap: Rap, + index: number, + parameters: RapENSActionParameters, + baseNonce?: number +): Promise => { + return ensAction( + wallet, + RapActionTypes.setTextENS, + index, + parameters, + ENSRegistrationTransactionType.SET_TEXT, + baseNonce + ); +}; + export default { commitENS, multicallENS, - registerENS, + registerWithConfig, + renewENS, setNameENS, setTextENS, }; diff --git a/src/raps/actions/swap.ts b/src/raps/actions/swap.ts index 563e80fd5f3..1ba361eb7af 100644 --- a/src/raps/actions/swap.ts +++ b/src/raps/actions/swap.ts @@ -1,6 +1,10 @@ import { Wallet } from '@ethersproject/wallet'; import { captureException } from '@sentry/react-native'; -import { Rap, RapActionParameters, SwapActionParameters } from '../common'; +import { + Rap, + RapExchangeActionParameters, + SwapActionParameters, +} from '../common'; import { ProtocolType, TransactionStatus, @@ -24,7 +28,7 @@ const swap = async ( wallet: Wallet, currentRap: Rap, index: number, - parameters: RapActionParameters, + parameters: RapExchangeActionParameters, baseNonce?: number ): Promise => { logger.log(`[${actionName}] base nonce`, baseNonce, 'index:', index); diff --git a/src/raps/actions/unlock.ts b/src/raps/actions/unlock.ts index b04f47b5720..bfc89c5bc25 100644 --- a/src/raps/actions/unlock.ts +++ b/src/raps/actions/unlock.ts @@ -4,7 +4,11 @@ import { Wallet } from '@ethersproject/wallet'; import { captureException } from '@sentry/react-native'; import { isNull, toLower } from 'lodash'; import { alwaysRequireApprove } from '../../config/debug'; -import { Rap, RapActionParameters, UnlockActionParameters } from '../common'; +import { + Rap, + RapExchangeActionParameters, + UnlockActionParameters, +} from '../common'; import { Asset, TransactionStatus, @@ -82,7 +86,7 @@ const unlock = async ( wallet: Wallet, currentRap: Rap, index: number, - parameters: RapActionParameters, + parameters: RapExchangeActionParameters, baseNonce?: number ): Promise => { logger.log(`[${actionName}] base nonce`, baseNonce, 'index:', index); diff --git a/src/raps/actions/withdrawCompound.ts b/src/raps/actions/withdrawCompound.ts index f5c2d4bd55a..78c312595ad 100644 --- a/src/raps/actions/withdrawCompound.ts +++ b/src/raps/actions/withdrawCompound.ts @@ -1,7 +1,11 @@ import { Contract } from '@ethersproject/contracts'; import { Wallet } from '@ethersproject/wallet'; import { captureException } from '@sentry/react-native'; -import { Rap, RapActionParameters, SwapActionParameters } from '../common'; +import { + Rap, + RapExchangeActionParameters, + SwapActionParameters, +} from '../common'; import { ProtocolType, TransactionStatus, @@ -30,7 +34,7 @@ const withdrawCompound = async ( wallet: Wallet, currentRap: Rap, index: number, - parameters: RapActionParameters, + parameters: RapExchangeActionParameters, baseNonce?: number ): Promise => { logger.log(`[${actionName}] base nonce`, baseNonce, 'index:', index); diff --git a/src/raps/common.ts b/src/raps/common.ts index cf98641b0d6..d8bc98523ef 100644 --- a/src/raps/common.ts +++ b/src/raps/common.ts @@ -11,7 +11,13 @@ import { unlock, withdrawCompound, } from './actions'; -import { createCommitENSRap } from './registerENS'; +import { + createCommitENSRap, + createRegisterENSRap, + createRenewENSRap, + createSetNameENSRap, + createSetRecordsENSRap, +} from './registerENS'; import { createSwapAndDepositCompoundRap, estimateSwapAndDepositCompound, @@ -21,18 +27,25 @@ import { createWithdrawFromCompoundRap, estimateWithdrawFromCompound, } from './withdrawFromCompound'; -import { createRegisterENSRap } from '.'; -import { Asset } from '@rainbow-me/entities'; +import { Asset, EthereumAddress, Records } from '@rainbow-me/entities'; import { estimateENSCommitGasLimit, estimateENSRegisterSetRecordsAndNameGasLimit, + estimateENSRenewGasLimit, + estimateENSSetNameGasLimit, + estimateENSSetRecordsGasLimit, } from '@rainbow-me/handlers/ens'; -import { ENSRegistrationRecords } from '@rainbow-me/helpers/ens'; import ExchangeModalTypes from '@rainbow-me/helpers/exchangeModalTypes'; - import logger from 'logger'; -const { commitENS, registerENS, multicallENS, setTextENS, setNameENS } = ens; +const { + commitENS, + registerWithConfig, + multicallENS, + setTextENS, + setNameENS, + renewENS, +} = ens; export enum RapActionType { depositCompound = 'depositCompound', @@ -42,24 +55,27 @@ export enum RapActionType { commitENS = 'commitENS', registerENS = 'registerENS', multicallENS = 'multicallENS', + renewENS = 'renewENS', setTextENS = 'setTextENS', setNameENS = 'setNameENS', } -export interface RapActionParameters { +export interface RapExchangeActionParameters { amount?: string | null; assetToUnlock?: Asset; contractAddress?: string; inputAmount?: string | null; outputAmount?: string | null; tradeDetails?: Trade; - name?: string; - duration?: number; - rentPrice?: string; - recordKey?: string; - recordValue?: string; - salt?: string; - records?: ENSRegistrationRecords; +} + +export interface RapENSActionParameters { + duration: number; + name: string; + ownerAddress: EthereumAddress; + rentPrice: string; + records?: Records; + salt: string; } export interface UnlockActionParameters { @@ -75,24 +91,37 @@ export interface SwapActionParameters { tradeDetails: Trade; } -export interface RegisterENSActionParameters { - nonce: number; - name: string; +export interface ENSActionParameters { duration: number; + nonce?: number; + name: string; rentPrice: string; ownerAddress: string; - recordKey: string; - recordValue: string; salt: string; - records: ENSRegistrationRecords; + records?: Records; + setReverseRecord?: boolean; + resolverAddress?: EthereumAddress; } export interface RapActionTransaction { hash: string | null; } -export interface RapAction { - parameters: RapActionParameters; +enum RAP_TYPE { + EXCHANGE = 'EXCHANGE', + ENS = 'ENS', +} + +export type RapAction = RapSwapAction | RapENSAction; + +export interface RapSwapAction { + parameters: RapExchangeActionParameters; + transaction: RapActionTransaction; + type: RapActionType; +} + +export interface RapENSAction { + parameters: RapENSActionParameters; transaction: RapActionTransaction; type: RapActionType; } @@ -101,6 +130,10 @@ export interface Rap { actions: RapAction[]; } +export interface ENSRap { + actions: RapENSAction[]; +} + interface RapActionResponse { baseNonce?: number | null; errorMessage: string | null; @@ -118,46 +151,51 @@ export const RapActionTypes = { multicallENS: 'multicallENS' as RapActionType, registerENS: 'registerENS' as RapActionType, registerWithConfigENS: 'registerWithConfigENS' as RapActionType, + renewENS: 'renewENS' as RapActionType, setNameENS: 'setNameENS' as RapActionType, + setRecordsENS: 'setRecordsENS' as RapActionType, setTextENS: 'setTextENS' as RapActionType, swap: 'swap' as RapActionType, unlock: 'unlock' as RapActionType, withdrawCompound: 'withdrawCompound' as RapActionType, }; -const createRapByType = ( +const createSwapRapByType = ( type: string, - { - swapParameters, - ensRegistrationParameters, - }: { - swapParameters: SwapActionParameters; - ensRegistrationParameters: RegisterENSActionParameters; - } + swapParameters: SwapActionParameters ) => { switch (type) { case ExchangeModalTypes.deposit: return createSwapAndDepositCompoundRap(swapParameters); case ExchangeModalTypes.withdrawal: return createWithdrawFromCompoundRap(swapParameters); + default: + return createUnlockAndSwapRap(swapParameters); + } +}; + +const createENSRapByType = ( + type: string, + ensRegistrationParameters: ENSActionParameters +) => { + switch (type) { case RapActionTypes.registerENS: return createRegisterENSRap(ensRegistrationParameters); + case RapActionTypes.renewENS: + return createRenewENSRap(ensRegistrationParameters); + case RapActionTypes.setNameENS: + return createSetNameENSRap(ensRegistrationParameters); + case RapActionTypes.setRecordsENS: + return createSetRecordsENSRap(ensRegistrationParameters); case RapActionTypes.commitENS: - return createCommitENSRap(ensRegistrationParameters); default: - return createUnlockAndSwapRap(swapParameters); + return createCommitENSRap(ensRegistrationParameters); } }; -export const getRapEstimationByType = ( +export const getSwapRapEstimationByType = ( type: string, - { - swapParameters, - ensRegistrationParameters, - }: { - swapParameters: SwapActionParameters; - ensRegistrationParameters: RegisterENSActionParameters; - } + swapParameters: SwapActionParameters ) => { switch (type) { case ExchangeModalTypes.deposit: @@ -166,18 +204,34 @@ export const getRapEstimationByType = ( return estimateUnlockAndSwap(swapParameters); case ExchangeModalTypes.withdrawal: return estimateWithdrawFromCompound(); + default: + return null; + } +}; + +export const getENSRapEstimationByType = ( + type: string, + ensRegistrationParameters: ENSActionParameters +) => { + switch (type) { case RapActionTypes.commitENS: return estimateENSCommitGasLimit(ensRegistrationParameters); case RapActionTypes.registerENS: return estimateENSRegisterSetRecordsAndNameGasLimit( ensRegistrationParameters ); + case RapActionTypes.renewENS: + return estimateENSRenewGasLimit(ensRegistrationParameters); + case RapActionTypes.setNameENS: + return estimateENSSetNameGasLimit(ensRegistrationParameters); + case RapActionTypes.setRecordsENS: + return estimateENSSetRecordsGasLimit(ensRegistrationParameters); default: return null; } }; -const findActionByType = (type: RapActionType) => { +const findSwapActionByType = (type: RapActionType) => { switch (type) { case RapActionTypes.unlock: return unlock; @@ -187,16 +241,25 @@ const findActionByType = (type: RapActionType) => { return depositCompound; case RapActionTypes.withdrawCompound: return withdrawCompound; + default: + return NOOP; + } +}; + +const findENSActionByType = (type: RapActionType) => { + switch (type) { case RapActionTypes.commitENS: return commitENS; - case RapActionTypes.registerENS: - return registerENS; + case RapActionTypes.registerWithConfigENS: + return registerWithConfig; case RapActionTypes.multicallENS: return multicallENS; case RapActionTypes.setTextENS: return setTextENS; case RapActionTypes.setNameENS: return setNameENS; + case RapActionTypes.renewENS: + return renewENS; default: return NOOP; } @@ -228,18 +291,32 @@ const executeAction = async ( baseNonce?: number ): Promise => { logger.log('[1 INNER] index', index); - const { parameters, type } = action; - const actionPromise = findActionByType(type); - logger.log('[2 INNER] executing type', type); + const { type, parameters } = action; + let nonce; try { - const nonce = await actionPromise( - wallet, - rap, - index, - parameters, - baseNonce - ); - return { baseNonce: nonce, errorMessage: null }; + logger.log('[2 INNER] executing type', type); + const rapType = getRapTypeFromActionType(type); + if (rapType === RAP_TYPE.ENS) { + const actionPromise = findENSActionByType(type); + nonce = await actionPromise( + wallet, + rap, + index, + parameters as RapENSActionParameters, + baseNonce + ); + return { baseNonce: nonce, errorMessage: null }; + } else { + const actionPromise = findSwapActionByType(type); + nonce = await actionPromise( + wallet, + rap, + index, + parameters as RapExchangeActionParameters, + baseNonce + ); + return { baseNonce: nonce, errorMessage: null }; + } } catch (error: any) { logger.sentry('[3 INNER] error running action, code:', error?.code); captureException(error); @@ -258,16 +335,41 @@ const executeAction = async ( } }; +const getRapTypeFromActionType = (actionType: RapActionType) => { + switch (actionType) { + case RapActionTypes.swap: + case RapActionTypes.unlock: + case RapActionTypes.depositCompound: + case RapActionTypes.withdrawCompound: + return RAP_TYPE.EXCHANGE; + case RapActionTypes.commitENS: + case RapActionTypes.registerENS: + case RapActionTypes.registerWithConfigENS: + case RapActionTypes.multicallENS: + case RapActionTypes.renewENS: + case RapActionTypes.setNameENS: + case RapActionTypes.setTextENS: + case RapActionTypes.setRecordsENS: + return RAP_TYPE.ENS; + } + return ''; +}; + export const executeRap = async ( wallet: Wallet, - type: string, - parameters: { - swapParameters: SwapActionParameters; - ensRegistrationParameters: RegisterENSActionParameters; - }, + type: RapActionType, + parameters: SwapActionParameters | ENSActionParameters, callback: (success?: boolean, errorMessage?: string | null) => void ) => { - const rap: Rap = await createRapByType(type, parameters); + const rapType = getRapTypeFromActionType(type); + + let rap: Rap = { actions: [] }; + if (rapType === RAP_TYPE.EXCHANGE) { + rap = await createSwapRapByType(type, parameters as SwapActionParameters); + } else if (rapType === RAP_TYPE.ENS) { + rap = await createENSRapByType(type, parameters as ENSActionParameters); + } + const { actions } = rap; const rapName = getRapFullName(actions); @@ -279,9 +381,7 @@ export const executeRap = async ( logger.log('[common - executing rap]: actions', actions); if (actions.length) { const firstAction = actions[0]; - const nonce = - parameters?.swapParameters?.nonce || - parameters?.ensRegistrationParameters?.nonce; + const nonce = parameters?.nonce; const { baseNonce, errorMessage } = await executeAction( firstAction, wallet, @@ -317,8 +417,23 @@ export const createNewRap = (actions: RapAction[]) => { export const createNewAction = ( type: RapActionType, - parameters: RapActionParameters -) => { + parameters: RapExchangeActionParameters +): RapSwapAction => { + const newAction = { + parameters, + rapType: RAP_TYPE.EXCHANGE, + transaction: { confirmed: null, hash: null }, + type, + }; + + logger.log('[common] Creating a new action', newAction); + return newAction; +}; + +export const createNewENSAction = ( + type: RapActionType, + parameters: ENSActionParameters +): RapENSAction => { const newAction = { parameters, transaction: { confirmed: null, hash: null }, diff --git a/src/raps/index.ts b/src/raps/index.ts index 19d3e411152..b3edbf5b7d6 100644 --- a/src/raps/index.ts +++ b/src/raps/index.ts @@ -7,5 +7,9 @@ export { estimateWithdrawFromCompound, createWithdrawFromCompoundRap, } from './withdrawFromCompound'; -export { executeRap, getRapEstimationByType } from './common'; -export { createRegisterENSRap } from './registerENS'; +export { executeRap, getSwapRapEstimationByType } from './common'; +export { + createRegisterENSRap, + createCommitENSRap, + createSetRecordsENSRap, +} from './registerENS'; diff --git a/src/raps/registerENS.ts b/src/raps/registerENS.ts index d4c9ca254e3..ffce3323b74 100644 --- a/src/raps/registerENS.ts +++ b/src/raps/registerENS.ts @@ -1,36 +1,111 @@ import { concat } from 'lodash'; import { - createNewAction, + createNewENSAction, createNewRap, - RapAction, + ENSActionParameters, RapActionTypes, - RegisterENSActionParameters, + RapENSAction, } from './common'; +import { + formatRecordsForTransaction, + recordsForTransactionAreValid, + shouldUseMulticallTransaction, +} from '@rainbow-me/handlers/ens'; + +export const createSetRecordsENSRap = async ( + ensActionParameters: ENSActionParameters +) => { + let actions: RapENSAction[] = []; + + const ensRegistrationRecords = formatRecordsForTransaction( + ensActionParameters.records + ); + const validRecords = recordsForTransactionAreValid(ensRegistrationRecords); + if (validRecords) { + const shouldUseMulticall = shouldUseMulticallTransaction( + ensRegistrationRecords + ); + const recordsAction = createNewENSAction( + shouldUseMulticall + ? RapActionTypes.multicallENS + : RapActionTypes.setTextENS, + ensActionParameters + ); + actions = concat(actions, recordsAction); + } + + // create the overall rap + const newRap = createNewRap(actions); + return newRap; +}; export const createRegisterENSRap = async ( - registerENSActionParameters: RegisterENSActionParameters + ensActionParameters: ENSActionParameters ) => { - let actions: RapAction[] = []; + let actions: RapENSAction[] = []; - const register = createNewAction( + const register = createNewENSAction( RapActionTypes.registerWithConfigENS, - registerENSActionParameters + ensActionParameters ); actions = concat(actions, register); - // ? records rap - const multicall = createNewAction( - RapActionTypes.multicallENS, - registerENSActionParameters + const ensRegistrationRecords = formatRecordsForTransaction( + ensActionParameters.records ); - actions = concat(actions, multicall); + const validRecords = recordsForTransactionAreValid(ensRegistrationRecords); + if (validRecords) { + const shouldUseMulticall = shouldUseMulticallTransaction( + ensRegistrationRecords + ); + const recordsAction = createNewENSAction( + shouldUseMulticall + ? RapActionTypes.multicallENS + : RapActionTypes.setTextENS, + ensActionParameters + ); + actions = concat(actions, recordsAction); + } - // ? reverse name rap - const setName = createNewAction( + if (ensActionParameters.setReverseRecord) { + const setName = createNewENSAction( + RapActionTypes.setNameENS, + ensActionParameters + ); + actions = concat(actions, setName); + } + + // create the overall rap + const newRap = createNewRap(actions); + return newRap; +}; + +export const createRenewENSRap = async ( + ENSActionParameters: ENSActionParameters +) => { + let actions: RapENSAction[] = []; + // // commit rap + const commit = createNewENSAction( + RapActionTypes.renewENS, + ENSActionParameters + ); + actions = concat(actions, commit); + + // create the overall rap + const newRap = createNewRap(actions); + return newRap; +}; + +export const createSetNameENSRap = async ( + ENSActionParameters: ENSActionParameters +) => { + let actions: RapENSAction[] = []; + // // commit rap + const commit = createNewENSAction( RapActionTypes.setNameENS, - registerENSActionParameters + ENSActionParameters ); - actions = concat(actions, setName); + actions = concat(actions, commit); // create the overall rap const newRap = createNewRap(actions); @@ -38,13 +113,13 @@ export const createRegisterENSRap = async ( }; export const createCommitENSRap = async ( - registerENSActionParameters: RegisterENSActionParameters + ENSActionParameters: ENSActionParameters ) => { - let actions: RapAction[] = []; + let actions: RapENSAction[] = []; // // commit rap - const commit = createNewAction( + const commit = createNewENSAction( RapActionTypes.commitENS, - registerENSActionParameters + ENSActionParameters ); actions = concat(actions, commit); diff --git a/src/react-query/types.ts b/src/react-query/types.ts new file mode 100644 index 00000000000..ca64258623e --- /dev/null +++ b/src/react-query/types.ts @@ -0,0 +1,18 @@ +import { UseQueryOptions } from 'react-query'; + +type PromiseValue = PromiseType extends PromiseLike + ? PromiseValue + : PromiseType; + +type ExtractFnReturnType any> = PromiseValue< + ReturnType +>; + +export type UseQueryData< + QueryFnType extends (...args: any) => any +> = ExtractFnReturnType; + +export type QueryConfig any> = Omit< + UseQueryOptions>, + 'queryKey' | 'queryFn' +>; diff --git a/src/redux/contacts.ts b/src/redux/contacts.ts index 2628c810458..22d609b9d52 100644 --- a/src/redux/contacts.ts +++ b/src/redux/contacts.ts @@ -29,6 +29,11 @@ export interface Contact { */ color: number; + /** + * The address's primary ens name + */ + ens: string; + /** * The network. */ @@ -89,7 +94,8 @@ export const contactsAddOrUpdate = ( address: string, nickname: string, color: number, - network: Network + network: Network, + ens: string ) => (dispatch: Dispatch, getState: AppGetState) => { const loweredAddress = toLower(address); const { contacts } = getState().contacts; @@ -98,6 +104,7 @@ export const contactsAddOrUpdate = ( [loweredAddress]: { address: loweredAddress, color, + ens, network, nickname, }, diff --git a/src/redux/ensRegistration.ts b/src/redux/ensRegistration.ts index 7bba6249d21..f7f996bbf2b 100644 --- a/src/redux/ensRegistration.ts +++ b/src/redux/ensRegistration.ts @@ -1,12 +1,25 @@ +import { subDays } from 'date-fns'; import { omit } from 'lodash'; +import { Dispatch } from 'react'; import { AppDispatch, AppGetState } from './store'; import { + ENSRegistrations, ENSRegistrationState, - EthereumAddress, Records, RegistrationParameters, + TransactionRegistrationParameters, } from '@rainbow-me/entities'; +import { + getLocalENSRegistrations, + saveLocalENSRegistrations, +} from '@rainbow-me/handlers/localstorage/accountLocal'; +import { NetworkTypes } from '@rainbow-me/helpers'; +import { REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; +const ENS_REGISTRATION_SET_CHANGED_RECORDS = + 'ensRegistration/ENS_REGISTRATION_SET_CHANGED_RECORDS'; +const ENS_REGISTRATION_SET_INITIAL_RECORDS = + 'ensRegistration/ENS_REGISTRATION_SET_INITIAL_RECORDS'; const ENS_REGISTRATION_UPDATE_DURATION = 'ensRegistration/ENS_REGISTRATION_UPDATE_DURATION'; const ENS_REGISTRATION_UPDATE_RECORDS = @@ -15,34 +28,58 @@ const ENS_REGISTRATION_UPDATE_RECORD_BY_KEY = 'ensRegistration/ENS_REGISTRATION_UPDATE_RECORD_BY_KEY'; const ENS_REGISTRATION_REMOVE_RECORD_BY_KEY = 'ensRegistration/ENS_REGISTRATION_REMOVE_RECORD_BY_KEY'; +const ENS_REMOVE_EXPIRED_REGISTRATIONS = + 'ensRegistration/ENS_REMOVE_EXPIRED_REGISTRATIONS'; const ENS_CONTINUE_REGISTRATION = 'ensRegistration/ENS_CONTINUE_REGISTRATION'; const ENS_START_REGISTRATION = 'ensRegistration/ENS_START_REGISTRATION'; const ENS_SAVE_COMMIT_REGISTRATION_PARAMETERS = 'ensRegistration/ENS_SAVE_COMMIT_REGISTRATION_PARAMETERS'; +const ENS_CLEAR_CURRENT_REGISTRATION_NAME = + 'ensRegistration/CLEAR_CURRENT_REGISTRATION_NAME'; +const ENS_UPDATE_REGISTRATION_PARAMETERS = + 'ensRegistration/ENS_UPDATE_REGISTRATION_PARAMETERS'; +const ENS_REMOVE_REGISTRATION_BY_NAME = + 'ensRegistration/ENS_REMOVE_REGISTRATION_BY_NAME'; +const ENS_LOAD_STATE = 'ensRegistration/ENS_LOAD_STATE'; + +interface EnsRegistrationSetChangedRecordsAction { + type: typeof ENS_REGISTRATION_SET_CHANGED_RECORDS; + payload: ENSRegistrationState; +} + +interface EnsRegistrationSetInitialRecordsAction { + type: typeof ENS_REGISTRATION_SET_INITIAL_RECORDS; + payload: ENSRegistrationState; +} interface EnsRegistrationUpdateDurationAction { type: typeof ENS_REGISTRATION_UPDATE_DURATION; - payload: ENSRegistrationState; + payload: { registrations: ENSRegistrations }; } interface EnsRegistrationUpdateRecordsAction { type: typeof ENS_REGISTRATION_UPDATE_RECORDS; - payload: ENSRegistrationState; + payload: { registrations: ENSRegistrations }; } interface EnsRegistrationUpdateRecordByKeyAction { type: typeof ENS_REGISTRATION_UPDATE_RECORD_BY_KEY; - payload: ENSRegistrationState; + payload: { registrations: ENSRegistrations }; } interface EnsRegistrationRemoveRecordByKeyAction { type: typeof ENS_REGISTRATION_REMOVE_RECORD_BY_KEY; - payload: ENSRegistrationState; + payload: { registrations: ENSRegistrations }; +} + +interface EnsRegistrationRemoveRegistrationByName { + type: typeof ENS_REMOVE_REGISTRATION_BY_NAME; + payload: { registrations: ENSRegistrations }; } interface EnsRegistrationContinueRegistrationAction { type: typeof ENS_CONTINUE_REGISTRATION; - payload: ENSRegistrationState; + payload: { registrations: ENSRegistrations }; } interface EnsRegistrationStartRegistrationAction { @@ -52,27 +89,75 @@ interface EnsRegistrationStartRegistrationAction { interface EnsRegistrationSaveCommitRegistrationParametersAction { type: typeof ENS_SAVE_COMMIT_REGISTRATION_PARAMETERS; - payload: ENSRegistrationState; + payload: { registrations: ENSRegistrations }; +} + +interface EnsRegistrationLoadState { + type: typeof ENS_LOAD_STATE; + payload: { registrations: ENSRegistrations }; +} + +interface EnsRemoveExpiredRegistrationsAction { + type: typeof ENS_REMOVE_EXPIRED_REGISTRATIONS; + payload: { registrations: ENSRegistrations }; +} + +interface EnsUpdateTransactionRegistrationParameters { + type: typeof ENS_UPDATE_REGISTRATION_PARAMETERS; + payload: { registrations: ENSRegistrations }; +} + +interface EnsClearCurrentRegistrationNameAction { + type: typeof ENS_CLEAR_CURRENT_REGISTRATION_NAME; } export type EnsRegistrationActionTypes = + | EnsRegistrationSetChangedRecordsAction + | EnsRegistrationSetInitialRecordsAction | EnsRegistrationUpdateDurationAction | EnsRegistrationUpdateRecordsAction | EnsRegistrationUpdateRecordByKeyAction | EnsRegistrationRemoveRecordByKeyAction + | EnsRegistrationRemoveRegistrationByName | EnsRegistrationContinueRegistrationAction | EnsRegistrationStartRegistrationAction - | EnsRegistrationSaveCommitRegistrationParametersAction; + | EnsRegistrationSaveCommitRegistrationParametersAction + | EnsClearCurrentRegistrationNameAction + | EnsRegistrationLoadState + | EnsRemoveExpiredRegistrationsAction + | EnsUpdateTransactionRegistrationParameters; // -- Actions ---------------------------------------- // + +/** + * Loads initial state from account local storage. + */ +export const ensRegistrationsLoadState = () => async ( + dispatch: Dispatch, + getState: AppGetState +) => { + const { accountAddress, network } = getState().settings; + try { + const registrations = await getLocalENSRegistrations( + accountAddress, + network + ); + dispatch({ + payload: { registrations }, + type: ENS_LOAD_STATE, + }); + // eslint-disable-next-line no-empty + } catch (error) {} +}; + export const startRegistration = ( - accountAddress: EthereumAddress, - name: string + name: string, + mode: keyof typeof REGISTRATION_MODES ) => async (dispatch: AppDispatch, getState: AppGetState) => { const { ensRegistration: { registrations }, + settings: { accountAddress }, } = getState(); - const lcAccountAddress = accountAddress.toLowerCase(); const accountRegistrations = registrations?.[lcAccountAddress] || {}; const registration = accountRegistrations[name] || {}; @@ -83,7 +168,7 @@ export const startRegistration = ( ...registrations, [lcAccountAddress]: { ...accountRegistrations, - [name]: { ...registration, name }, + [name]: { ...registration, mode, name }, }, }, }; @@ -105,39 +190,102 @@ export const continueRegistration = (name: string) => async ( }); }; -export const updateRegistrationDuration = ( - accountAddress: EthereumAddress, - duration: number -) => async (dispatch: AppDispatch, getState: AppGetState) => { +export const removeExpiredRegistrations = () => async ( + dispatch: AppDispatch, + getState: AppGetState +) => { + const { + ensRegistration: { registrations }, + settings: { accountAddress }, + } = getState(); + + const accountRegistrations = + registrations?.[accountAddress.toLowerCase()] || []; + + const registrationsArray = Object.values(accountRegistrations); + + const sevenDaysAgoMs = subDays(new Date(), 7).getTime(); + + const activeRegistrations = registrationsArray.filter(registration => + registration?.commitTransactionConfirmedAt + ? registration?.commitTransactionConfirmedAt >= sevenDaysAgoMs + : true + ); + + dispatch({ + payload: activeRegistrations, + type: ENS_REMOVE_EXPIRED_REGISTRATIONS, + }); +}; + +export const setInitialRecords = (records: Records) => async ( + dispatch: AppDispatch, + getState: AppGetState +) => { const { ensRegistration: { registrations, currentRegistrationName }, + settings: { accountAddress }, } = getState(); const lcAccountAddress = accountAddress.toLowerCase(); + const accountRegistrations = registrations?.[lcAccountAddress] || {}; + const registration = accountRegistrations[currentRegistrationName] || {}; + const updatedEnsRegistrationManagerForAccount = { + registrations: { + ...registrations, + [lcAccountAddress]: { + ...accountRegistrations, + [currentRegistrationName]: { + ...registration, + initialRecords: records, + records, + }, + }, + }, + }; + dispatch({ + payload: updatedEnsRegistrationManagerForAccount, + type: ENS_REGISTRATION_SET_INITIAL_RECORDS, + }); +}; + +export const setChangedRecords = (changedRecords: Records) => async ( + dispatch: AppDispatch, + getState: AppGetState +) => { + const { + ensRegistration: { registrations, currentRegistrationName }, + settings: { accountAddress }, + } = getState(); + const lcAccountAddress = accountAddress.toLowerCase(); const accountRegistrations = registrations?.[lcAccountAddress] || {}; const registration = accountRegistrations[currentRegistrationName] || {}; - const updatedEnsRegistrationManager = { + const updatedEnsRegistrationManagerForAccount = { registrations: { ...registrations, [lcAccountAddress]: { ...accountRegistrations, - [currentRegistrationName]: { ...registration, duration }, + [currentRegistrationName]: { + ...registration, + changedRecords, + }, }, }, }; dispatch({ - payload: updatedEnsRegistrationManager, - type: ENS_REGISTRATION_UPDATE_DURATION, + payload: updatedEnsRegistrationManagerForAccount, + type: ENS_REGISTRATION_SET_CHANGED_RECORDS, }); }; -export const updateRecords = ( - accountAddress: EthereumAddress, - records: Records -) => async (dispatch: AppDispatch, getState: AppGetState) => { +export const updateRecords = (records: Records) => async ( + dispatch: AppDispatch, + getState: AppGetState +) => { const { ensRegistration: { registrations, currentRegistrationName }, + settings: { accountAddress }, } = getState(); const lcAccountAddress = accountAddress.toLowerCase(); const accountRegistrations = registrations?.[lcAccountAddress] || {}; @@ -158,20 +306,19 @@ export const updateRecords = ( }); }; -export const updateRecordByKey = ( - accountAddress: EthereumAddress, - key: string, - value: string -) => async (dispatch: AppDispatch, getState: AppGetState) => { +export const updateRecordByKey = (key: string, value: string) => async ( + dispatch: AppDispatch, + getState: AppGetState +) => { const { ensRegistration: { registrations, currentRegistrationName }, + settings: { accountAddress }, } = getState(); const lcAccountAddress = accountAddress.toLowerCase(); const accountRegistrations = registrations?.[lcAccountAddress] || {}; const registration = accountRegistrations[currentRegistrationName] || {}; const registrationRecords = registration?.records || {}; - const updatedEnsRegistrationManagerForAccount = { registrations: { ...registrations, @@ -190,12 +337,13 @@ export const updateRecordByKey = ( }); }; -export const removeRecordByKey = ( - accountAddress: EthereumAddress, - key: string -) => async (dispatch: AppDispatch, getState: AppGetState) => { +export const removeRecordByKey = (key: string) => async ( + dispatch: AppDispatch, + getState: AppGetState +) => { const { ensRegistration: { registrations, currentRegistrationName }, + settings: { accountAddress }, } = getState(); const lcAccountAddress = accountAddress.toLowerCase(); @@ -225,17 +373,18 @@ export const removeRecordByKey = ( }; export const saveCommitRegistrationParameters = ( - accountAddress: EthereumAddress, - registrationParameters: RegistrationParameters + registrationParameters: + | RegistrationParameters + | TransactionRegistrationParameters ) => async (dispatch: AppDispatch, getState: AppGetState) => { const { ensRegistration: { registrations, currentRegistrationName }, + settings: { accountAddress }, } = getState(); - const lcAccountAddress = accountAddress.toLowerCase(); const accountRegistrations = registrations?.[lcAccountAddress] || {}; const registration = accountRegistrations[currentRegistrationName] || {}; - const updatedEnsRegistrationManagerForAccount = { + const updatedEnsRegistrationManager = { registrations: { ...registrations, [lcAccountAddress]: { @@ -248,12 +397,95 @@ export const saveCommitRegistrationParameters = ( }, }; + saveLocalENSRegistrations( + updatedEnsRegistrationManager.registrations, + accountAddress, + NetworkTypes.mainnet + ); + dispatch({ - payload: updatedEnsRegistrationManagerForAccount, + payload: updatedEnsRegistrationManager, type: ENS_SAVE_COMMIT_REGISTRATION_PARAMETERS, }); }; +export const clearCurrentRegistrationName = () => async ( + dispatch: AppDispatch +) => { + dispatch({ + type: ENS_CLEAR_CURRENT_REGISTRATION_NAME, + }); +}; + +export const updateTransactionRegistrationParameters = ( + registrationParameters: TransactionRegistrationParameters +) => async (dispatch: AppDispatch, getState: AppGetState) => { + const { + ensRegistration: { registrations, currentRegistrationName }, + settings: { accountAddress }, + } = getState(); + + const lcAccountAddress = accountAddress.toLowerCase(); + const accountRegistrations = registrations?.[lcAccountAddress] || {}; + const registration = accountRegistrations[currentRegistrationName] || {}; + const updatedEnsRegistrationManager = { + registrations: { + ...registrations, + [lcAccountAddress]: { + ...accountRegistrations, + [currentRegistrationName]: { + ...registration, + ...registrationParameters, + }, + }, + }, + }; + + saveLocalENSRegistrations( + updatedEnsRegistrationManager.registrations, + accountAddress, + NetworkTypes.mainnet + ); + + dispatch({ + payload: updatedEnsRegistrationManager, + type: ENS_UPDATE_REGISTRATION_PARAMETERS, + }); +}; + +export const removeRegistrationByName = (name: string) => async ( + dispatch: AppDispatch, + getState: AppGetState +) => { + const { + ensRegistration: { registrations }, + settings: { accountAddress }, + } = getState(); + + const lcAccountAddress = accountAddress.toLowerCase(); + const accountRegistrations = registrations?.[lcAccountAddress] || {}; + delete accountRegistrations?.[name]; + const updatedEnsRegistrationManager = { + registrations: { + ...registrations, + [lcAccountAddress]: { + ...accountRegistrations, + }, + }, + }; + + saveLocalENSRegistrations( + updatedEnsRegistrationManager.registrations, + accountAddress, + NetworkTypes.mainnet + ); + + dispatch({ + payload: updatedEnsRegistrationManager, + type: ENS_UPDATE_REGISTRATION_PARAMETERS, + }); +}; + // -- Reducer ----------------------------------------- // const INITIAL_STATE: ENSRegistrationState = { currentRegistrationName: '', @@ -270,6 +502,16 @@ export default ( ...state, ...action.payload, }; + case ENS_REGISTRATION_SET_CHANGED_RECORDS: + return { + ...state, + ...action.payload, + }; + case ENS_REGISTRATION_SET_INITIAL_RECORDS: + return { + ...state, + ...action.payload, + }; case ENS_REGISTRATION_UPDATE_DURATION: return { ...state, @@ -290,11 +532,36 @@ export default ( ...state, ...action.payload, }; + case ENS_REMOVE_EXPIRED_REGISTRATIONS: + return { + ...state, + ...action.payload, + }; case ENS_SAVE_COMMIT_REGISTRATION_PARAMETERS: return { ...state, ...action.payload, }; + case ENS_UPDATE_REGISTRATION_PARAMETERS: + return { + ...state, + ...action.payload, + }; + case ENS_REMOVE_REGISTRATION_BY_NAME: + return { + ...state, + ...action.payload, + }; + case ENS_CLEAR_CURRENT_REGISTRATION_NAME: + return { + ...state, + currentRegistrationName: '', + }; + case ENS_LOAD_STATE: + return { + ...state, + ...action.payload, + }; default: return state; } diff --git a/src/redux/gas.ts b/src/redux/gas.ts index 0c6ce3eb0e4..5e4f0aead5a 100644 --- a/src/redux/gas.ts +++ b/src/redux/gas.ts @@ -532,6 +532,7 @@ export const gasPricesStartPolling = (network = Network.mainnet) => async ( }, gasFeeParamsBySpeed: gasFeeParamsBySpeed, gasFeesBySpeed, + gasLimit: _gasLimit, isSufficientGas, isValidGas, selectedGasFee: updatedSelectedGasFee, @@ -722,6 +723,7 @@ export default ( currentBlockParams: action.payload.currentBlockParams, gasFeeParamsBySpeed: action.payload.gasFeeParamsBySpeed, gasFeesBySpeed: action.payload.gasFeesBySpeed, + gasLimit: action.payload.gasLimit, isSufficientGas: action.payload.isSufficientGas, isValidGas: action.payload.isValidGas, selectedGasFee: action.payload.selectedGasFee, diff --git a/src/redux/uniqueTokens.ts b/src/redux/uniqueTokens.ts index 459cba5cc3b..660267fb815 100644 --- a/src/redux/uniqueTokens.ts +++ b/src/redux/uniqueTokens.ts @@ -1,16 +1,22 @@ import analytics from '@segment/analytics-react-native'; import { captureException } from '@sentry/react-native'; -import { concat, isEmpty, without } from 'lodash'; +import { concat, isEmpty, uniqBy, without } from 'lodash'; import { Dispatch } from 'redux'; import { ThunkDispatch } from 'redux-thunk'; +import { + applyENSMetadataFallbackToToken, + applyENSMetadataFallbackToTokens, +} from '../parsers/uniqueTokens'; import { dataUpdateAssets } from './data'; import { AppGetState, AppState } from './store'; import { UniqueAsset } from '@rainbow-me/entities'; +import { fetchEnsTokens } from '@rainbow-me/handlers/ens'; import { getUniqueTokens, saveUniqueTokens, } from '@rainbow-me/handlers/localstorage/accountLocal'; import { + apiGetAccountUniqueToken, apiGetAccountUniqueTokens, UNIQUE_TOKENS_LIMIT_PER_PAGE, UNIQUE_TOKENS_LIMIT_TOTAL, @@ -242,7 +248,7 @@ export const fetchUniqueTokens = (showcaseAddress?: string) => async ( const accountAddress = showcaseAddress || getState().settings.accountAddress; const { accountAssetsData } = getState().data; const { uniqueTokens: existingUniqueTokens } = getState().uniqueTokens; - const shouldUpdateInBatches = isEmpty(existingUniqueTokens); + let shouldUpdateInBatches = isEmpty(existingUniqueTokens); let uniqueTokens: UniqueAsset[] = []; let errorCheck = false; @@ -266,11 +272,16 @@ export const fetchUniqueTokens = (showcaseAddress?: string) => async ( const fetchPage = async (page: number, network: Network) => { let shouldStopFetching = false; try { - const newPageResults = await apiGetAccountUniqueTokens( + let newPageResults = await apiGetAccountUniqueTokens( network, accountAddress, page ); + + // If there are any "unknown" ENS names, fallback to the ENS + // metadata service. + newPageResults = await applyENSMetadataFallbackToTokens(newPageResults); + uniqueTokens = concat(uniqueTokens, newPageResults); shouldStopFetching = newPageResults.length < UNIQUE_TOKENS_LIMIT_PER_PAGE || @@ -328,6 +339,17 @@ export const fetchUniqueTokens = (showcaseAddress?: string) => async ( analytics.identify(null, { NFTs: uniqueTokens.length }); } + // Fetch recently registered ENS tokens (OpenSea doesn't recognize these for a while). + // We will fetch tokens registered in the past 48 hours to be safe. + const ensTokens = await fetchEnsTokens({ + address: accountAddress, + timeAgo: { hours: 48 }, + }); + if (ensTokens.length > 0) { + uniqueTokens = uniqBy([...uniqueTokens, ...ensTokens], 'id'); + shouldUpdateInBatches = false; + } + // NFT Fetching clean up // check that the account address to fetch for has not changed while fetching before updating state const isCurrentAccountAddress = @@ -344,6 +366,62 @@ export const fetchUniqueTokens = (showcaseAddress?: string) => async ( } }; +/** + * Revalidates a unique token via OpenSea API, updates state, and saves to local storage. + * + * Note: it is intentional that there are no loading states dispatched in this action. This + * is for _revalidation_ purposes only. + * + * @param contractAddress - The contract address of the NFT + * @param tokenId - The tokenId of the NFT + * @param {Object} config - Optional configuration + * @param {boolean} config.forceUpdate - Trigger a force update of metadata (equivalent to refreshing metadata in OpenSea) + */ +export const revalidateUniqueToken = ( + contractAddress: string, + tokenId: string, + { forceUpdate = false }: { forceUpdate?: boolean } = {} +) => async ( + dispatch: ThunkDispatch< + AppState, + unknown, + UniqueTokensGetAction | UniqueTokensClearStateShowcaseAction + >, + getState: AppGetState +) => { + const { network: currentNetwork } = getState().settings; + const { uniqueTokens: existingUniqueTokens } = getState().uniqueTokens; + const accountAddress = getState().settings.accountAddress; + + let token = await apiGetAccountUniqueToken( + currentNetwork, + contractAddress, + tokenId, + { forceUpdate } + ); + + // If the token is an "unknown" ENS name, fallback to the ENS + // metadata service. + try { + token = await applyENSMetadataFallbackToToken(token); + } catch (error) { + captureException(error); + } + + const uniqueTokens = existingUniqueTokens.map(existingToken => + existingToken.id === tokenId ? token : existingToken + ); + + saveUniqueTokens(uniqueTokens, accountAddress, currentNetwork); + dispatch({ + payload: uniqueTokens, + showcase: false, + type: UNIQUE_TOKENS_GET_UNIQUE_TOKENS_SUCCESS, + }); + + return token; +}; + // -- Reducer --------------------------------------------------------------- // export const INITIAL_UNIQUE_TOKENS_STATE: UniqueTokensState = { diff --git a/src/redux/wallets.js b/src/redux/wallets.js index 64713e14a76..94b75b93477 100644 --- a/src/redux/wallets.js +++ b/src/redux/wallets.js @@ -7,7 +7,6 @@ import { getWalletNames, saveWalletNames, } from '../handlers/localstorage/walletNames'; -import { web3Provider } from '../handlers/web3'; import WalletBackupTypes from '../helpers/walletBackupTypes'; import WalletTypes from '../helpers/walletTypes'; import { hasKey } from '../model/keychain'; @@ -31,9 +30,10 @@ import { } from '../utils/keychainConstants'; import { addressHashedColorIndex, - lookupAddressWithRetry, + fetchReverseRecordWithRetry, } from '../utils/profileUtils'; import { updateWebDataEnabled } from './showcaseTokens'; +import { fetchImages, fetchReverseRecord } from '@rainbow-me/handlers/ens'; import { lightModeThemeColors } from '@rainbow-me/styles'; // -- Constants --------------------------------------- // @@ -45,7 +45,10 @@ const WALLETS_SET_IS_LOADING = 'wallets/WALLETS_SET_IS_LOADING'; const WALLETS_SET_SELECTED = 'wallets/SET_SELECTED'; // -- Actions ---------------------------------------- // -export const walletsLoadState = () => async (dispatch, getState) => { +export const walletsLoadState = (profilesEnabled = false) => async ( + dispatch, + getState +) => { try { const { accountAddress } = getState().settings; let addressFromKeychain = accountAddress; @@ -102,7 +105,6 @@ export const walletsLoadState = () => async (dispatch, getState) => { } const walletNames = await getWalletNames(); - dispatch({ payload: { selected: selectedWallet, @@ -113,6 +115,7 @@ export const walletsLoadState = () => async (dispatch, getState) => { }); dispatch(fetchWalletNames()); + profilesEnabled && dispatch(fetchWalletENSAvatars()); return wallets; } catch (error) { logger.sentry('Exception during walletsLoadState'); @@ -224,6 +227,64 @@ export const createAccountForWallet = (id, color, name) => async ( return newWallets; }; +export const getWalletENSAvatars = async (walletsState, dispatch) => { + const { wallets, walletNames, selected } = walletsState; + const walletKeys = Object.keys(wallets); + let updatedWallets; + let promises = []; + walletKeys.forEach(key => { + const wallet = wallets[key]; + const innerPromises = wallet?.addresses?.map(async account => { + const ens = + (await fetchReverseRecord(account.address)) || + walletNames[account.address]; + if (ens) { + const images = await fetchImages(ens); + const newImage = + typeof images?.avatarUrl === 'string' && + images?.avatarUrl !== account?.image + ? images?.avatarUrl + : account.image; + return { + account: { + ...account, + image: newImage, + }, + avatarChanged: newImage !== account.image, + key, + }; + } else { + return { account, avatarChanged: false, key }; + } + }); + promises = promises.concat(innerPromises); + }); + + const newAccounts = await Promise.all(promises); + newAccounts.forEach(({ account, key, avatarChanged }) => { + if (!avatarChanged) return; + const addresses = wallets?.[key]?.addresses; + const index = addresses?.findIndex( + ({ address }) => address === account.address + ); + addresses.splice(index, 1, account); + updatedWallets = { + ...(updatedWallets ?? wallets), + [key]: { + ...wallets[key], + addresses, + }, + }; + }); + if (updatedWallets) { + dispatch(walletsSetSelected(updatedWallets[selected.id])); + dispatch(walletsUpdate(updatedWallets)); + } +}; + +export const fetchWalletENSAvatars = () => async (dispatch, getState) => + getWalletENSAvatars(getState().wallets, dispatch); + export const fetchWalletNames = () => async (dispatch, getState) => { const { wallets } = getState().wallets; const updatedWalletNames = {}; @@ -234,10 +295,7 @@ export const fetchWalletNames = () => async (dispatch, getState) => { const visibleAccounts = filter(wallet.addresses, 'visible'); return map(visibleAccounts, async account => { try { - const ens = await lookupAddressWithRetry( - web3Provider, - account.address - ); + const ens = await fetchReverseRecordWithRetry(account.address); if (ens && ens !== account.address) { updatedWalletNames[account.address] = ens; } diff --git a/src/references/ens-intro-marquee-names.json b/src/references/ens-intro-marquee-names.json new file mode 100644 index 00000000000..d02414837fb --- /dev/null +++ b/src/references/ens-intro-marquee-names.json @@ -0,0 +1,18 @@ +[ + "nickbytes.eth", + "vitalik.eth", + "achal.eth", + "sassal.eth", + "alisha.eth", + "moxey.eth", + "pug.eth", + "simona.eth", + "shaq.eth", + "nick.eth", + "ped.eth", + "maaria.eth", + "cantino.eth", + "jstn.eth", + "notben.eth", + "sarahguo.eth" +] \ No newline at end of file diff --git a/src/references/ethereum-units.json b/src/references/ethereum-units.json index fd4075789e1..5e94e1f493e 100644 --- a/src/references/ethereum-units.json +++ b/src/references/ethereum-units.json @@ -7,9 +7,9 @@ "ens_register_with_config": 280000, "ens_commit": 50000, "ens_set_text": 60000, - "ens_set_name": 120000, - "ens_set_multicall": 90000, - "ens_registration": 320000, + "ens_set_name": 60000, + "ens_set_multicall": 270000, + "ens_registration": 600000, "basic_tx": 21000, "basic_withdrawal": 550000, "basic_transfer": 50000, diff --git a/src/references/index.ts b/src/references/index.ts index 7a0925d0e76..6c557a6f26e 100644 --- a/src/references/index.ts +++ b/src/references/index.ts @@ -21,6 +21,7 @@ export { SIGNATURE_REGISTRY_ADDRESS, } from './signatureRegistry'; export { default as emojis } from './emojis.json'; +export { default as ensIntroMarqueeNames } from './ens-intro-marquee-names.json'; export { default as erc20ABI } from './erc20-abi.json'; export { default as optimismGasOracleAbi } from './optimism-gas-oracle-abi.json'; export { default as ethUnits } from './ethereum-units.json'; diff --git a/src/screens/ChangeWalletSheet.js b/src/screens/ChangeWalletSheet.js index 2b661b18833..68eda27301c 100644 --- a/src/screens/ChangeWalletSheet.js +++ b/src/screens/ChangeWalletSheet.js @@ -27,6 +27,7 @@ import { walletsSetSelected, walletsUpdate, } from '../redux/wallets'; +import { PROFILES, useExperimentalFlag } from '@rainbow-me/config'; import WalletBackupTypes from '@rainbow-me/helpers/walletBackupTypes'; import { useAccountSettings, @@ -40,6 +41,7 @@ import styled from '@rainbow-me/styled-components'; import { abbreviations, deviceUtils, + doesWalletsContainAddress, showActionSheetWithOptions, } from '@rainbow-me/utils'; @@ -120,6 +122,7 @@ export default function ChangeWalletSheet() { const initializeWallet = useInitializeWallet(); const walletsWithBalancesAndNames = useWalletsWithBalancesAndNames(); const creatingWallet = useRef(); + const profilesEnabled = useExperimentalFlag(PROFILES); const [editMode, setEditMode] = useState(false); const [currentAddress, setCurrentAddress] = useState( @@ -354,18 +357,13 @@ export default function ChangeWalletSheet() { // If we're deleting the selected wallet // we need to switch to another one if (address === currentAddress) { - for (let i = 0; i < Object.keys(wallets).length; i++) { - const key = Object.keys(wallets)[i]; - const someWallet = wallets[key]; - const found = someWallet.addresses.find( - account => - account.visible && account.address !== address - ); - - if (found) { - await onChangeAccount(key, found.address, true); - break; - } + const { wallet: foundWallet, key } = + doesWalletsContainAddress({ + address: address, + wallets, + }) || {}; + if (foundWallet) { + await onChangeAccount(key, foundWallet.address, true); } } } @@ -470,7 +468,7 @@ export default function ChangeWalletSheet() { // If doesn't exist, we need to create a new wallet } else { await createWallet(null, color, name); - await dispatch(walletsLoadState()); + await dispatch(walletsLoadState(profilesEnabled)); await initializeWallet(); } } catch (e) { @@ -508,6 +506,7 @@ export default function ChangeWalletSheet() { selectedWallet.primary, setIsWalletLoading, wallets, + profilesEnabled, ]); const onPressImportSeedPhrase = useCallback(() => { diff --git a/src/screens/ENSAdditionalRecordsSheet.tsx b/src/screens/ENSAdditionalRecordsSheet.tsx new file mode 100644 index 00000000000..781560287d7 --- /dev/null +++ b/src/screens/ENSAdditionalRecordsSheet.tsx @@ -0,0 +1,94 @@ +import { useRoute } from '@react-navigation/core'; +import React, { useMemo } from 'react'; +import { useWindowDimensions } from 'react-native'; +import { useRecoilState } from 'recoil'; +import SelectableButton from '../components/ens-registration/TextRecordsForm/SelectableButton'; +import { SlackSheet } from '../components/sheet'; +import { AccentColorProvider, Box, Inline } from '@rainbow-me/design-system'; +import { accentColorAtom, textRecordFields } from '@rainbow-me/helpers/ens'; +import { useENSRegistrationForm } from '@rainbow-me/hooks'; +import { deviceUtils } from '@rainbow-me/utils'; + +export const ENSAdditionalRecordsSheetHeight = 262; +const recordLineHeight = 30; + +export const getENSAdditionalRecordsSheetHeight = () => { + const deviceWidth = deviceUtils.dimensions.width; + if (deviceWidth > 400) return ENSAdditionalRecordsSheetHeight; + if (deviceWidth > 380) + return ENSAdditionalRecordsSheetHeight + recordLineHeight; + return ENSAdditionalRecordsSheetHeight + 2 * recordLineHeight; +}; + +export default function ENSAdditionalRecordsSheet() { + const { params } = useRoute(); + const [accentColor] = useRecoilState(accentColorAtom); + const { + selectedFields, + onAddField, + onRemoveField, + } = useENSRegistrationForm(); + const { height: deviceHeight } = useWindowDimensions(); + + const boxStyle = useMemo( + () => ({ + height: params.longFormHeight || ENSAdditionalRecordsSheetHeight, + }), + [params.longFormHeight] + ); + + const androidTop = deviceHeight - boxStyle.height - recordLineHeight; + + return ( + // @ts-expect-error JavaScript component + + + + + {Object.values(textRecordFields).map((textRecordField, i) => { + const isSelected = selectedFields.some( + field => field.id === textRecordField.id + ); + return ( + { + if (isSelected) { + const index = selectedFields.findIndex( + ({ id }) => textRecordField.id === id + ); + const fieldToRemove = selectedFields[index]; + let newFields = [...selectedFields]; + newFields.splice(index, 1); + onRemoveField(fieldToRemove, newFields); + } else { + const fieldToAdd = textRecordField; + const newSelectedFields = [...selectedFields]; + newSelectedFields.splice(i, 0, fieldToAdd); + onAddField(fieldToAdd, newSelectedFields); + } + }} + testID={`ens-selectable-attribute-${textRecordField.id}`} + > + {textRecordField.label} + + ); + })} + + + + + ); +} diff --git a/src/screens/ENSAssignRecordsSheet.js b/src/screens/ENSAssignRecordsSheet.js deleted file mode 100644 index 59f525c55f1..00000000000 --- a/src/screens/ENSAssignRecordsSheet.js +++ /dev/null @@ -1,391 +0,0 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; -import { Keyboard, Pressable } from 'react-native'; -import RadialGradient from 'react-native-radial-gradient'; -import Animated, { - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated'; -import Svg, { Path } from 'react-native-svg'; -import { useRecoilState } from 'recoil'; -import ButtonPressAnimation from '../components/animations/ButtonPressAnimation'; -import { MiniButton } from '../components/buttons'; -import TintButton from '../components/buttons/TintButton'; -import { TextRecordsForm } from '../components/ens-registration'; -import SelectableButton from '../components/ens-registration/TextRecordsForm/SelectableButton'; -import { SheetActionButton, SheetActionButtonRow } from '../components/sheet'; -import { useTheme } from '../context/ThemeContext'; -import { useNavigation } from '../navigation/Navigation'; -// import { usePersistentDominantColorFromImage } from '@rainbow-me/hooks'; -import { - AccentColorProvider, - BackgroundProvider, - Bleed, - Box, - Cover, - Heading, - Inline, - Inset, - Row, - Rows, - Stack, - Text, -} from '@rainbow-me/design-system'; -import { - accentColorAtom, - ENS_RECORDS, - textRecordFields, -} from '@rainbow-me/helpers/ens'; -import { - useENSProfile, - useENSProfileForm, - useKeyboardHeight, -} from '@rainbow-me/hooks'; -import Routes from '@rainbow-me/routes'; - -const AnimatedBox = Animated.createAnimatedComponent(Box); - -export const BottomActionHeight = ios ? 270 : 250; -const avatarSize = 70; -const alpha = '33'; - -export default function ENSAssignRecordsSheet() { - const { colors } = useTheme(); - const { name } = useENSProfile(); - - const [accentColor, setAccentColor] = useRecoilState(accentColorAtom); - useEffect(() => { - setAccentColor(colors.purple); - }, [colors.purple, setAccentColor]); - // usePersistentDominantColorFromImage('TODO').result || colors.purple; // add this when we implement avatars - - const { - selectedFields, - onChangeField, - onBlurField, - values, - } = useENSProfileForm({ - defaultFields: [ - textRecordFields[ENS_RECORDS.displayName], - textRecordFields[ENS_RECORDS.description], - textRecordFields[ENS_RECORDS.url], - textRecordFields[ENS_RECORDS.twitter], - ], - }); - - const handleChooseCover = useCallback(() => { - // TODO - }, []); - - const handleChooseAvatar = useCallback(() => { - // TODO - }, []); - - return ( - - ({ paddingBottom: BottomActionHeight + 20 }), [])} - > - - - - - 􀣵 Add Cover - - - - - - - - - {({ backgroundColor }) => ( - - - - )} - - - - - - - - {` 􀣵 `} - - - - - - - - - - - - - {name} - - - Create your profile - - - - - - - - - - - ); -} - -export function ENSAssignRecordsBottomActions({ visible }) { - const { navigate } = useNavigation(); - const keyboardHeight = useKeyboardHeight(); - const [accentColor] = useRecoilState(accentColorAtom); - - const { - isEmpty, - selectedFields, - onAddField, - onRemoveField, - } = useENSProfileForm(); - - const handlePressBack = useCallback(() => { - navigate(Routes.ENS_SEARCH_SHEET); - }, [navigate]); - - const handlePressContinue = useCallback(() => { - navigate(Routes.ENS_CONFIRM_REGISTER_SHEET, { - accentColor, - avatarUrl: null, - }); - }, [accentColor, navigate]); - - const animatedStyle = useAnimatedStyle(() => { - return { - bottom: withTiming(visible ? 0 : -BottomActionHeight - 10, { - duration: 100, - }), - }; - }); - - const keyboardButtonWrapperStyle = useMemo( - () => ({ bottom: keyboardHeight }), - [keyboardHeight] - ); - - return ( - <> - {visible && ( - - - - - - )} - - - ({ height: BottomActionHeight }), [])} - > - {ios ? : null} - - - - - - - - - - 􀆉 Back - - {isEmpty ? ( - - Skip - - ) : ( - - )} - - - - - - - - ); -} - -function HideKeyboardButton({ color }) { - const show = useSharedValue(false); - - useEffect(() => { - const handleShowKeyboard = () => (show.value = true); - const handleHideKeyboard = () => (show.value = false); - - const showListener = android ? 'keyboardDidShow' : 'keyboardWillShow'; - const hideListener = android ? 'keyboardDidHide' : 'keyboardWillHide'; - - Keyboard.addListener(showListener, handleShowKeyboard); - Keyboard.addListener(hideListener, handleHideKeyboard); - return () => { - Keyboard.removeListener(showListener, handleShowKeyboard); - Keyboard.removeListener(hideListener, handleHideKeyboard); - }; - }, [show]); - - const style = useAnimatedStyle(() => { - return { - opacity: withTiming(show.value ? 1 : 0, { duration: 100 }), - }; - }); - - return ( - - Keyboard.dismiss()} - style={useMemo(() => ({ height: 30, width: 30 }), [])} - width={30} - > - 􀆈 - - - ); -} - -function Shadow() { - return ( - <> - - - - - - - - ); -} - -function SelectableAttributesButtons({ - selectedFields, - onAddField, - onRemoveField, -}) { - return ( - - {Object.values(textRecordFields).map((textRecordField, i) => { - const isSelected = selectedFields.some( - field => field.id === textRecordField.id - ); - return ( - { - if (isSelected) { - const index = selectedFields.findIndex( - ({ id }) => textRecordField.id === id - ); - const fieldToRemove = selectedFields[index]; - let newFields = [...selectedFields]; - newFields.splice(index, 1); - onRemoveField(fieldToRemove, newFields); - } else { - const fieldToAdd = textRecordField; - onAddField(fieldToAdd, [...selectedFields, fieldToAdd]); - } - }} - > - {textRecordField.label} - - ); - })} - - ); -} diff --git a/src/screens/ENSAssignRecordsSheet.tsx b/src/screens/ENSAssignRecordsSheet.tsx new file mode 100644 index 00000000000..43bcd9a9bee --- /dev/null +++ b/src/screens/ENSAssignRecordsSheet.tsx @@ -0,0 +1,563 @@ +import { useFocusEffect, useRoute } from '@react-navigation/core'; +import lang from 'i18n-js'; +import { isEmpty } from 'lodash'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Keyboard, ScrollView } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import { useRecoilState } from 'recoil'; +import { ButtonPressAnimation } from '../components/animations/'; +import TintButton from '../components/buttons/TintButton'; +import { + RegistrationAvatar, + RegistrationCover, + TextRecordsForm, +} from '../components/ens-registration'; +import SelectableButton from '../components/ens-registration/TextRecordsForm/SelectableButton'; +import { SheetActionButton, SheetActionButtonRow } from '../components/sheet'; +import { useTheme } from '../context/ThemeContext'; +import { delayNext } from '../hooks/useMagicAutofocus'; +import { useNavigation } from '../navigation/Navigation'; +import { + ENSConfirmRegisterSheetHeight, + ENSConfirmUpdateSheetHeight, +} from './ENSConfirmRegisterSheet'; +import { + AccentColorProvider, + Bleed, + Box, + Cover, + Heading, + Inline, + Inset, + Row, + Rows, + Stack, + Text, +} from '@rainbow-me/design-system'; +import { + getSeenOnchainDataDisclaimer, + saveSeenOnchainDataDisclaimer, +} from '@rainbow-me/handlers/localstorage/ens'; +import { + accentColorAtom, + ENS_RECORDS, + REGISTRATION_MODES, + TextRecordField, + textRecordFields, +} from '@rainbow-me/helpers/ens'; +import { + useDimensions, + useENSModifiedRegistration, + useENSRegistration, + useENSRegistrationCosts, + useENSRegistrationForm, + useENSRegistrationStepHandler, + useENSSearch, + useKeyboardHeight, + usePersistentDominantColorFromImage, +} from '@rainbow-me/hooks'; +import Routes from '@rainbow-me/routes'; + +const BottomActionHeight = ios ? 281 : 250; +const BottomActionHeightSmall = 215; + +export default function ENSAssignRecordsSheet() { + const { params } = useRoute(); + const { colors } = useTheme(); + const { isSmallPhone } = useDimensions(); + const { name } = useENSRegistration(); + const { + images: { avatarUrl: initialAvatarUrl }, + } = useENSModifiedRegistration({ + modifyChangedRecords: true, + setInitialRecordsWhenInEditMode: true, + }); + + const { data: registrationData } = useENSSearch({ + name, + }); + const { step } = useENSRegistrationStepHandler(); + + const defaultFields = useMemo( + () => + [ + ENS_RECORDS.displayName, + ENS_RECORDS.description, + ENS_RECORDS.url, + ENS_RECORDS.twitter, + ].map(fieldName => textRecordFields[fieldName] as TextRecordField), + [] + ); + const { profileQuery, isLoading } = useENSRegistrationForm({ + defaultFields, + initializeForm: true, + }); + + const displayTitleLabel = + params.mode !== REGISTRATION_MODES.EDIT || !isLoading; + const isEmptyProfile = isEmpty(profileQuery.data?.records); + + useENSRegistrationCosts({ + name, + rentPrice: registrationData?.rentPrice, + step, + yearsDuration: 1, + }); + + const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl); + const [accentColor, setAccentColor] = useRecoilState(accentColorAtom); + + const avatarImage = + avatarUrl || initialAvatarUrl || params?.externalAvatarUrl || ''; + const { result: dominantColor } = usePersistentDominantColorFromImage( + avatarImage + ); + + const bottomActionHeight = isSmallPhone + ? BottomActionHeightSmall + : BottomActionHeight; + + useFocusEffect(() => { + if (dominantColor || (!dominantColor && !avatarImage)) { + setAccentColor(dominantColor || colors.purple); + } + }); + + const handleAutoFocusLayout = useCallback( + ({ + nativeEvent: { + layout: { y }, + }, + }) => { + params?.sheetRef.current.scrollTo({ y }); + }, + [params?.sheetRef] + ); + + const handleError = useCallback( + ({ yOffset }) => { + params?.sheetRef.current.scrollTo({ y: yOffset }); + }, + [params?.sheetRef] + ); + + const [hasSeenExplainSheet, setHasSeenExplainSheet] = useState(false); + + useEffect(() => { + (async () => { + setHasSeenExplainSheet(Boolean(await getSeenOnchainDataDisclaimer())); + })(); + }, []); + + const { navigate } = useNavigation(); + + const handleFocus = useCallback(() => { + if (!hasSeenExplainSheet) { + android && Keyboard.dismiss(); + navigate(Routes.EXPLAIN_SHEET, { + type: 'ensOnChainDataWarning', + }); + setHasSeenExplainSheet(true); + saveSeenOnchainDataDisclaimer(true); + } + }, [hasSeenExplainSheet, navigate, setHasSeenExplainSheet]); + + return ( + + + + + + + + + + + + + + {name} + + + {displayTitleLabel + ? lang.t( + `profiles.${ + isEmptyProfile && + params.mode !== REGISTRATION_MODES.EDIT + ? 'create' + : 'edit' + }.label` + ) + : ''} + + + + + + + + + + + ); +} + +export function ENSAssignRecordsBottomActions({ + visible: defaultVisible, + previousRouteName, + currentRouteName, +}: { + visible: boolean; + previousRouteName?: string; + currentRouteName: string; +}) { + const { navigate, goBack } = useNavigation(); + const { isSmallPhone } = useDimensions(); + const keyboardHeight = useKeyboardHeight(); + const { colors } = useTheme(); + const [accentColor, setAccentColor] = useRecoilState(accentColorAtom); + const { mode } = useENSRegistration(); + const [fromRoute, setFromRoute] = useState(previousRouteName); + const { + disabled, + isEmpty, + selectedFields, + onAddField, + onRemoveField, + submit, + values, + } = useENSRegistrationForm(); + const { profileQuery } = useENSModifiedRegistration(); + const handlePressBack = useCallback(() => { + delayNext(); + navigate(fromRoute); + setAccentColor(colors.purple); + }, [colors.purple, fromRoute, navigate, setAccentColor]); + + const hasBackButton = useMemo( + () => + fromRoute === Routes.ENS_SEARCH_SHEET || + fromRoute === Routes.ENS_INTRO_SHEET || + fromRoute === Routes.ENS_ASSIGN_RECORDS_SHEET, + [fromRoute] + ); + + useEffect(() => { + if (previousRouteName !== currentRouteName) { + setFromRoute(previousRouteName); + } + }, [currentRouteName, previousRouteName]); + + const handlePressContinue = useCallback(() => { + submit(() => { + navigate(Routes.ENS_CONFIRM_REGISTER_SHEET, { + longFormHeight: + mode === REGISTRATION_MODES.EDIT + ? ENSConfirmUpdateSheetHeight + : ENSConfirmRegisterSheetHeight + (values.avatar ? 70 : 0), + }); + }); + }, [mode, navigate, submit, values.avatar]); + + const navigateToAdditionalRecords = useCallback(() => { + android && Keyboard.dismiss(); + navigate(Routes.ENS_ADDITIONAL_RECORDS_SHEET, {}); + }, [navigate]); + + const [visible, setVisible] = useState(false); + useEffect(() => { + if (mode === REGISTRATION_MODES.EDIT) { + setTimeout(() => setVisible(profileQuery.isSuccess), 200); + } else { + setVisible(defaultVisible); + } + }, [defaultVisible, mode, profileQuery.isSuccess]); + + const bottomActionHeight = isSmallPhone + ? BottomActionHeightSmall + : BottomActionHeight; + + const animatedStyle = useAnimatedStyle(() => { + return { + bottom: withSpring(visible ? 0 : -bottomActionHeight - 10, { + damping: 40, + mass: 1, + stiffness: 420, + }), + }; + }); + + const keyboardButtonWrapperStyle = useMemo( + () => ({ bottom: keyboardHeight }), + [keyboardHeight] + ); + + return ( + <> + {visible && ( + + + + + + )} + + + + {ios ? : null} + + + + + + + + {/* @ts-expect-error JavaScript component */} + + {hasBackButton && ( + + {lang.t('profiles.create.back')} + + )} + {isEmpty && mode === REGISTRATION_MODES.CREATE ? ( + + {lang.t('profiles.create.skip')} + + ) : ( + + {!disabled ? ( + + ) : ( + goBack()} + testID="ens-assign-records-cancel" + > + {lang.t(`profiles.create.cancel`)} + + )} + + )} + + + + + + + + ); +} + +function HideKeyboardButton({ color }: { color: string }) { + const show = useSharedValue(false); + + useEffect(() => { + const handleShowKeyboard = () => (show.value = true); + const handleHideKeyboard = () => (show.value = false); + + const showListener = android ? 'keyboardDidShow' : 'keyboardWillShow'; + const hideListener = android ? 'keyboardDidHide' : 'keyboardWillHide'; + + Keyboard.addListener(showListener, handleShowKeyboard); + Keyboard.addListener(hideListener, handleHideKeyboard); + return () => { + Keyboard.removeListener(showListener, handleShowKeyboard); + Keyboard.removeListener(hideListener, handleHideKeyboard); + }; + }, [show]); + + const style = useAnimatedStyle(() => { + return { + opacity: withTiming(show.value ? 1 : 0, { duration: 100 }), + }; + }); + + return ( + + Keyboard.dismiss()} scaleTo={0.8}> + + + + + 􀆈 + + + + + + + ); +} + +function Shadow() { + return ( + <> + + + + + + + + ); +} + +const MAX_DISPLAY_BUTTONS = 9; + +function SelectableAttributesButtons({ + selectedFields, + onAddField, + onRemoveField, + navigateToAdditionalRecords, +}: { + selectedFields: TextRecordField[]; + onAddField: ( + fieldsToAdd: TextRecordField, + selectedFields: TextRecordField[] + ) => void; + onRemoveField: ( + fieldsToRemove: TextRecordField, + selectedFields: TextRecordField[] + ) => void; + navigateToAdditionalRecords: () => void; +}) { + const dotsButtonIsSelected = useMemo(() => { + const nonPrimaryRecordsIds = Object.values(textRecordFields) + .slice(MAX_DISPLAY_BUTTONS) + .map(({ id }) => id); + const dotsSelected = selectedFields.some(field => + nonPrimaryRecordsIds.includes(field.id) + ); + return dotsSelected; + }, [selectedFields]); + + return ( + + {Object.values(textRecordFields) + .slice(0, MAX_DISPLAY_BUTTONS) + .map((textRecordField, i) => { + const isSelected = selectedFields.some( + field => field.id === textRecordField.id + ); + return ( + { + if (isSelected) { + const index = selectedFields.findIndex( + ({ id }) => textRecordField.id === id + ); + const fieldToRemove = selectedFields[index]; + let newFields = [...selectedFields]; + newFields.splice(index, 1); + onRemoveField(fieldToRemove, newFields); + } else { + const fieldToAdd = textRecordField; + const newSelectedFields = [...selectedFields]; + newSelectedFields.splice(i, 0, fieldToAdd); + onAddField(fieldToAdd, newSelectedFields); + } + }} + testID={`ens-selectable-attribute-${textRecordField.id}`} + > + {textRecordField.label} + + ); + })} + + 􀍠 + + + ); +} diff --git a/src/screens/ENSConfirmRegisterSheet.js b/src/screens/ENSConfirmRegisterSheet.js deleted file mode 100644 index b3d18065a12..00000000000 --- a/src/screens/ENSConfirmRegisterSheet.js +++ /dev/null @@ -1,222 +0,0 @@ -import { useRoute } from '@react-navigation/core'; -import { isEmpty } from 'lodash'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import brain from '../assets/brain.png'; -import { HoldToAuthorizeButton } from '../components/buttons'; -import { RegistrationReviewRows } from '../components/ens-registration'; -import { GasSpeedButton } from '../components/gas'; -import { SheetActionButtonRow, SlackSheet } from '../components/sheet'; -import { RapActionTypes } from '../raps/common'; -import { - AccentColorProvider, - Box, - Divider, - Heading, - Inline, - Inset, - Stack, - Text, -} from '@rainbow-me/design-system'; -import { ENS_DOMAIN, generateSalt } from '@rainbow-me/helpers/ens'; -import { - useAccountSettings, - useCurrentNonce, - useENSProfile, - useENSRegistration, - useENSRegistrationCosts, - useGas, -} from '@rainbow-me/hooks'; -import { ImgixImage } from '@rainbow-me/images'; -import { loadWallet } from '@rainbow-me/model/wallet'; -import { getRapEstimationByType } from '@rainbow-me/raps'; -import { saveCommitRegistrationParameters } from '@rainbow-me/redux/ensRegistration'; -import { timeUnits } from '@rainbow-me/references'; - -export const ENSConfirmRegisterSheetHeight = 600; -const avatarSize = 70; - -export default function ENSConfirmRegisterSheet() { - const dispatch = useDispatch(); - const { gasFeeParamsBySpeed, updateTxFee, startPollingGasFees } = useGas(); - const { name: ensName, records } = useENSProfile(); - const { accountAddress, network } = useAccountSettings(); - const getNextNonce = useCurrentNonce(accountAddress, network); - const [gasLimit, setGasLimit] = useState(); - const { params } = useRoute(); - - const [duration, setDuration] = useState(1); - - const name = ensName.replace(ENS_DOMAIN, ''); - const { data: registrationData } = useENSRegistration({ - name, - }); - const rentPrice = registrationData?.rentPrice; - const { data: registrationCostsData } = useENSRegistrationCosts({ - duration, - name, - rentPrice, - }); - - const updateGasLimit = useCallback(async () => { - const salt = generateSalt(); - const gasLimit = await getRapEstimationByType(RapActionTypes.commitENS, { - ensRegistrationParameters: { - duration: duration * timeUnits.secs.year, - name: name, - ownerAddress: accountAddress, - records, - rentPrice, - salt, - }, - }); - updateTxFee(gasLimit); - setGasLimit(gasLimit); - }, [accountAddress, duration, name, records, rentPrice, updateTxFee]); - - // Update gas limit - useEffect(() => { - if (!gasLimit && !isEmpty(gasFeeParamsBySpeed)) { - updateGasLimit(); - } - }, [gasFeeParamsBySpeed, gasLimit, updateGasLimit, updateTxFee]); - - useEffect(() => startPollingGasFees(), [startPollingGasFees]); - - const handleCommitSubmit = useCallback(async () => { - const wallet = await loadWallet(); - if (!wallet) { - return; - } - - const nonce = await getNextNonce(); - const salt = generateSalt(); - - const ensRegistrationParameters = { - duration: duration * timeUnits.secs.year, - name, - nonce, - ownerAddress: accountAddress, - records, - rentPrice, - salt, - }; - - dispatch( - saveCommitRegistrationParameters( - accountAddress, - ensRegistrationParameters - ) - ); - return; - // LEAVING THIS AS WIP TO AVOID PEOPLE ON THE TEAM SENDING THIS TX - - // const callback = () => null; - - // await executeRap( - // wallet, - // RapActionTypes.commitENS, - // { ensRegistrationParameters }, - // callback - // ); - }, [ - accountAddress, - dispatch, - duration, - getNextNonce, - name, - records, - rentPrice, - ]); - - return ( - - - ({ height: ENSConfirmRegisterSheetHeight }), [])} - > - - - - {params.avatarUrl && ( - - )} - {ensName} - - Confirm purchase - - - - - - - - - ({ height: 20, width: 20 }), [])} - /> - - - Buy more years now to save on fees - - - - - - - - ({ bottom: 0 }), [])}> - - - - - - - - - - - - - ); -} diff --git a/src/screens/ENSConfirmRegisterSheet.tsx b/src/screens/ENSConfirmRegisterSheet.tsx new file mode 100644 index 00000000000..398feaa40d9 --- /dev/null +++ b/src/screens/ENSConfirmRegisterSheet.tsx @@ -0,0 +1,440 @@ +import { useFocusEffect, useRoute } from '@react-navigation/core'; +import lang from 'i18n-js'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { InteractionManager } from 'react-native'; +import * as DeviceInfo from 'react-native-device-info'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { HoldToAuthorizeButton } from '../components/buttons'; +import { + CommitContent, + RegisterContent, + RenewContent, + WaitCommitmentConfirmationContent, + WaitENSConfirmationContent, +} from '../components/ens-registration'; +import { avatarMetadataAtom } from '../components/ens-registration/RegistrationAvatar/RegistrationAvatar'; +import { GasSpeedButton } from '../components/gas'; +import { SheetActionButtonRow, SlackSheet } from '../components/sheet'; +import { + AccentColorProvider, + Box, + Heading, + Inset, + Row, + Rows, + Stack, + Text, +} from '@rainbow-me/design-system'; +import { + accentColorAtom, + ENS_DOMAIN, + ENS_SECONDS_WAIT, + REGISTRATION_MODES, + REGISTRATION_STEPS, +} from '@rainbow-me/helpers/ens'; +import { + useAccountProfile, + useDimensions, + useENSModifiedRegistration, + useENSRegistration, + useENSRegistrationActionHandler, + useENSRegistrationCosts, + useENSRegistrationForm, + useENSRegistrationStepHandler, + useENSSearch, + usePersistentDominantColorFromImage, +} from '@rainbow-me/hooks'; +import { ImgixImage } from '@rainbow-me/images'; +import { useNavigation } from '@rainbow-me/navigation'; +import Routes from '@rainbow-me/routes'; +import { colors } from '@rainbow-me/styles'; + +export const ENSConfirmRegisterSheetHeight = 600; +export const ENSConfirmRenewSheetHeight = ios ? 500 : 560; +export const ENSConfirmUpdateSheetHeight = 400; +const avatarSize = 60; + +function TransactionActionRow({ + action, + accentColor, + label, + isValidGas, + isSufficientGas, + testID, +}: { + action: any; + accentColor?: string; + label: string; + isValidGas: boolean; + isSufficientGas: boolean; + testID: string; +}) { + const insufficientEth = isSufficientGas === false && isValidGas; + return ( + <> + + {/* @ts-expect-error JavaScript component */} + + {/* @ts-expect-error JavaScript component */} + + + + + {/* @ts-expect-error JavaScript component */} + + + + ); +} + +export default function ENSConfirmRegisterSheet() { + const { params } = useRoute(); + const { name: ensName, mode } = useENSRegistration(); + const { + images: { avatarUrl: initialAvatarUrl }, + } = useENSModifiedRegistration(); + const { isSmallPhone } = useDimensions(); + + const [accentColor, setAccentColor] = useRecoilState(accentColorAtom); + const avatarMetadata = useRecoilValue(avatarMetadataAtom); + + const avatarImage = + avatarMetadata?.path || initialAvatarUrl || params?.externalAvatarUrl || ''; + const { result: dominantColor } = usePersistentDominantColorFromImage( + avatarImage + ); + + useEffect(() => { + if (dominantColor || (!dominantColor && !avatarImage)) { + setAccentColor(dominantColor || colors.purple); + } + }, [avatarImage, dominantColor, setAccentColor]); + + const [duration, setDuration] = useState(1); + + const { navigate, goBack } = useNavigation(); + + const { blurFields, values } = useENSRegistrationForm(); + const accountProfile = useAccountProfile(); + + const avatarUrl = initialAvatarUrl || values.avatar; + + const name = ensName?.replace(ENS_DOMAIN, ''); + const { data: registrationData } = useENSSearch({ + name, + }); + + const [sendReverseRecord, setSendReverseRecord] = useState( + !accountProfile.accountENS + ); + const { step, secondsSinceCommitConfirmed } = useENSRegistrationStepHandler( + false + ); + const { action } = useENSRegistrationActionHandler({ + sendReverseRecord, + step, + yearsDuration: duration, + }); + + const { data: registrationCostsData } = useENSRegistrationCosts({ + name: ensName, + rentPrice: registrationData?.rentPrice, + sendReverseRecord, + step, + yearsDuration: duration, + }); + const { clearCurrentRegistrationName } = useENSRegistration(); + + const goToProfileScreen = useCallback(() => { + InteractionManager.runAfterInteractions(() => { + goBack(); + setTimeout(() => { + navigate(Routes.PROFILE_SCREEN); + }, 100); + }); + }, [goBack, navigate]); + + const stepLabel = useMemo(() => { + if (mode === REGISTRATION_MODES.EDIT) + return lang.t('profiles.confirm.confirm_update'); + if (mode === REGISTRATION_MODES.RENEW) + return lang.t('profiles.confirm.extend_registration'); + if (step === REGISTRATION_STEPS.COMMIT) + return lang.t('profiles.confirm.registration_details'); + if (step === REGISTRATION_STEPS.WAIT_COMMIT_CONFIRMATION) + return lang.t('profiles.confirm.requesting_register'); + if (step === REGISTRATION_STEPS.WAIT_ENS_COMMITMENT) + return lang.t('profiles.confirm.reserving_name'); + if (step === REGISTRATION_STEPS.REGISTER) + return lang.t('profiles.confirm.confirm_registration'); + if (step === REGISTRATION_STEPS.SET_NAME) + return lang.t('profiles.confirm.set_name_registration'); + }, [mode, step]); + + const onMountSecondsSinceCommitConfirmed = useMemo( + () => secondsSinceCommitConfirmed, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const stepContent = useMemo( + () => ({ + [REGISTRATION_STEPS.COMMIT]: ( + + ), + [REGISTRATION_STEPS.REGISTER]: ( + + ), + [REGISTRATION_STEPS.EDIT]: null, + [REGISTRATION_STEPS.SET_NAME]: null, + [REGISTRATION_STEPS.RENEW]: ( + + ), + [REGISTRATION_STEPS.WAIT_COMMIT_CONFIRMATION]: ( + action(accentColor)} + /> + ), + [REGISTRATION_STEPS.WAIT_ENS_COMMITMENT]: ( + 0 + ? onMountSecondsSinceCommitConfirmed + : 0) + } + /> + ), + }), + [ + duration, + registrationCostsData, + accentColor, + sendReverseRecord, + name, + onMountSecondsSinceCommitConfirmed, + action, + ] + ); + + const stepActions = useMemo( + () => ({ + [REGISTRATION_STEPS.COMMIT]: ( + + ), + [REGISTRATION_STEPS.REGISTER]: ( + action(goToProfileScreen)} + isSufficientGas={Boolean( + registrationCostsData?.isSufficientGasForStep + )} + isValidGas={Boolean( + registrationCostsData?.isValidGas && + registrationCostsData?.stepGasLimit + )} + label={lang.t('profiles.confirm.confirm_registration')} + testID={step} + /> + ), + [REGISTRATION_STEPS.RENEW]: ( + { + action(); + goToProfileScreen(); + }} + isSufficientGas={Boolean( + registrationCostsData?.isSufficientGasForRegistration && + registrationCostsData?.isSufficientGasForStep + )} + isValidGas={Boolean( + registrationCostsData?.isValidGas && + registrationCostsData?.stepGasLimit + )} + label={lang.t('profiles.confirm.confirm_renew')} + testID={step} + /> + ), + [REGISTRATION_STEPS.EDIT]: ( + action(goToProfileScreen)} + isSufficientGas={Boolean( + registrationCostsData?.isSufficientGasForStep + )} + isValidGas={Boolean( + registrationCostsData?.isValidGas && + registrationCostsData?.stepGasLimit + )} + label={lang.t('profiles.confirm.confirm_update')} + testID={step} + /> + ), + [REGISTRATION_STEPS.SET_NAME]: ( + action(goToProfileScreen)} + isSufficientGas={Boolean( + registrationCostsData?.isSufficientGasForStep + )} + isValidGas={Boolean( + registrationCostsData?.isValidGas && + registrationCostsData?.stepGasLimit + )} + label={lang.t('profiles.confirm.confirm_set_name')} + testID={step} + /> + ), + [REGISTRATION_STEPS.WAIT_COMMIT_CONFIRMATION]: null, + [REGISTRATION_STEPS.WAIT_ENS_COMMITMENT]: null, + }), + [ + accentColor, + action, + registrationCostsData?.isSufficientGasForRegistration, + registrationCostsData?.isSufficientGasForStep, + registrationCostsData?.isValidGas, + registrationCostsData?.stepGasLimit, + step, + goToProfileScreen, + ] + ); + + useFocusEffect( + useCallback(() => { + blurFields(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + ); + + useEffect( + () => () => { + if ( + step === REGISTRATION_STEPS.RENEW || + step === REGISTRATION_STEPS.SET_NAME + ) + clearCurrentRegistrationName(); + }, + [clearCurrentRegistrationName, step] + ); + + return ( + // @ts-expect-error JavaScript component + + + + + + {/* @ts-expect-error JavaScript component */} + + + {avatarUrl && ( + + + + )} + + + {ensName} + + + + {stepLabel} + + + + + + + {stepContent[step]} + + + + {stepActions[step]} + + + + ); +} diff --git a/src/screens/ENSIntroSheet.tsx b/src/screens/ENSIntroSheet.tsx new file mode 100644 index 00000000000..dafbdbfc855 --- /dev/null +++ b/src/screens/ENSIntroSheet.tsx @@ -0,0 +1,387 @@ +import MaskedView from '@react-native-masked-view/masked-view'; +import { useRoute } from '@react-navigation/core'; +import lang from 'i18n-js'; +import React, { useCallback, useMemo } from 'react'; +import { InteractionManager } from 'react-native'; +import { + ContextMenuButton, + MenuActionConfig, +} from 'react-native-ios-context-menu'; +import LinearGradient from 'react-native-linear-gradient'; +import ActivityIndicator from '../components/ActivityIndicator'; +import IntroMarquee from '../components/ens-registration/IntroMarquee/IntroMarquee'; +import { SheetActionButton } from '../components/sheet'; +import { useNavigation } from '../navigation/Navigation'; +import { useTheme } from '@rainbow-me/context'; +import { + Bleed, + Box, + Column, + Columns, + Divider, + Heading, + Inset, + Row, + Rows, + Stack, + Text, +} from '@rainbow-me/design-system'; +import { REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; +import { + useAccountENSDomains, + useAccountProfile, + useAccountSettings, + useDimensions, + useENSRegistration, +} from '@rainbow-me/hooks'; +import Routes from '@rainbow-me/routes'; +import { showActionSheetWithOptions } from '@rainbow-me/utils'; + +enum AnotherENSEnum { + search = 'search', + my_ens = 'my_ens', +} +const topPadding = android ? 29 : 19; + +const minHeight = 740; + +export default function ENSIntroSheet() { + const { width: deviceWidth, height: deviceHeight } = useDimensions(); + const { colors } = useTheme(); + const { params } = useRoute(); + const { accountAddress } = useAccountSettings(); + const { data: domains, isLoading, isSuccess } = useAccountENSDomains(); + const { accountENS } = useAccountProfile(); + + // We are not using `isSmallPhone` from `useDimensions` here as we + // want to explicitly set a min height. + const isSmallPhone = deviceHeight < minHeight; + + const contentHeight = params?.contentHeight; + const contentWidth = Math.min(deviceWidth - 72, 300); + + const { ownedDomains, primaryDomain, nonPrimaryDomains } = useMemo(() => { + const ownedDomains = domains?.filter( + ({ owner }) => owner?.id?.toLowerCase() === accountAddress.toLowerCase() + ); + return { + nonPrimaryDomains: + ownedDomains?.filter(({ name }) => accountENS !== name) || [], + ownedDomains, + primaryDomain: ownedDomains?.find(({ name }) => accountENS === name), + }; + }, [accountAddress, accountENS, domains]); + + const uniqueDomain = useMemo(() => { + return primaryDomain + ? primaryDomain + : nonPrimaryDomains?.length === 1 + ? nonPrimaryDomains?.[0] + : null; + }, [nonPrimaryDomains, primaryDomain]); + + const { navigate } = useNavigation(); + const { startRegistration } = useENSRegistration(); + + const handleNavigateToSearch = useCallback(() => { + params?.onSearchForNewName?.(); + InteractionManager.runAfterInteractions(() => { + startRegistration('', REGISTRATION_MODES.CREATE); + navigate(Routes.ENS_SEARCH_SHEET); + }); + }, [navigate, params, startRegistration]); + + const navigateToAssignRecords = useCallback( + (ensName: string) => { + startRegistration(ensName, REGISTRATION_MODES.EDIT); + InteractionManager.runAfterInteractions(() => { + params?.onSelectExistingName?.(); + navigate(Routes.ENS_ASSIGN_RECORDS_SHEET); + }); + }, + [navigate, params, startRegistration] + ); + + const handleSelectUniqueDomain = useCallback(() => { + !!uniqueDomain && navigateToAssignRecords(uniqueDomain?.name); + }, [navigateToAssignRecords, uniqueDomain]); + + const handleSelectExistingName = useCallback(() => { + navigate(Routes.SELECT_ENS_SHEET, { + onSelectENS: (ensName: string) => { + navigateToAssignRecords(ensName); + }, + }); + }, [navigate, navigateToAssignRecords]); + + const menuConfig = useMemo(() => { + return { + menuItems: [ + { + actionKey: AnotherENSEnum.my_ens, + actionTitle: lang.t('profiles.intro.my_ens_names'), + icon: { + iconType: 'SYSTEM', + iconValue: 'rectangle.stack.badge.person.crop', + }, + }, + { + actionKey: AnotherENSEnum.search, + actionTitle: lang.t('profiles.intro.search_new_ens'), + icon: { + iconType: 'SYSTEM', + iconValue: 'magnifyingglass', + }, + }, + ] as MenuActionConfig[], + menuTitle: '', + }; + }, []); + + const onPressAndroidActions = useCallback(() => { + const androidActions = [ + lang.t('profiles.intro.my_ens_names'), + lang.t('profiles.intro.search_new_ens'), + ] as const; + + showActionSheetWithOptions( + { + options: androidActions, + showSeparators: true, + title: '', + }, + (idx: number) => { + if (idx === 0) { + handleSelectExistingName(); + } else if (idx === 1) { + handleNavigateToSearch(); + } + } + ); + }, [handleNavigateToSearch, handleSelectExistingName]); + + const handlePressMenuItem = useCallback( + ({ nativeEvent: { actionKey } }) => { + if (actionKey === AnotherENSEnum.my_ens) { + handleSelectExistingName(); + } else if (actionKey === AnotherENSEnum.search) { + handleNavigateToSearch(); + } + }, + [handleNavigateToSearch, handleSelectExistingName] + ); + + return ( + + + + + + + + + {lang.t('profiles.intro.create_your')} + + + {lang.t('profiles.intro.ens_profile')} + + + + + + + + + + + + + + + + + + + + + + + + + + + {isLoading && ( + + {/* @ts-expect-error JavaScript component */} + + + )} + {isSuccess && ( + <> + {ownedDomains?.length === 0 ? ( + + ) : ( + + {uniqueDomain ? ( + + ) : ( + + )} + {nonPrimaryDomains?.length > 0 ? ( + + + + ) : ( + + )} + + )} + + )} + + + + + + + + ); +} + +function InfoRow({ + icon, + title, + description, +}: { + icon: string; + title: string; + description: string; +}) { + const { colors } = useTheme(); + + return ( + + + + + {icon} + +
+ } + style={{ width: 42 }} + > + + + + + + {title} + + {description} + + + + + ); +} diff --git a/src/screens/ENSSearchSheet.js b/src/screens/ENSSearchSheet.js deleted file mode 100644 index 8be6626f9db..00000000000 --- a/src/screens/ENSSearchSheet.js +++ /dev/null @@ -1,202 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { Keyboard } from 'react-native'; -import { KeyboardArea } from 'react-native-keyboard-area'; -import { useDispatch } from 'react-redux'; -import { useDebounce } from 'use-debounce'; -import dice from '../assets/dice.png'; -import TintButton from '../components/buttons/TintButton'; -import { - SearchInput, - SearchResultGradientIndicator, -} from '../components/ens-registration'; -import { SheetActionButton, SheetActionButtonRow } from '../components/sheet'; -import { useNavigation } from '../navigation/Navigation'; -import { - Box, - Divider, - Heading, - Inline, - Inset, - Stack, - Text, -} from '@rainbow-me/design-system'; -import { ENS_DOMAIN } from '@rainbow-me/helpers/ens'; -import { - useAccountSettings, - useENSRegistration, - useENSRegistrationCosts, - useKeyboardHeight, -} from '@rainbow-me/hooks'; -import { ImgixImage } from '@rainbow-me/images'; -import { startRegistration } from '@rainbow-me/redux/ensRegistration'; -import Routes from '@rainbow-me/routes'; -import { colors } from '@rainbow-me/styles'; -import { normalizeENS } from '@rainbow-me/utils'; - -export default function ENSSearchSheet() { - const dispatch = useDispatch(); - const { navigate } = useNavigation(); - const keyboardHeight = useKeyboardHeight(); - const { accountAddress } = useAccountSettings(); - - const topPadding = android ? 29 : 19; - - const [searchQuery, setSearchQuery] = useState(''); - const [debouncedSearchQuery] = useDebounce(searchQuery, 200); - - const { - data: registrationData, - isIdle, - isRegistered, - isLoading, - isInvalid, - isAvailable, - } = useENSRegistration({ - name: debouncedSearchQuery, - }); - - const { - data: registrationCostsData, - isSuccess: registrationCostsDataIsAvailable, - } = useENSRegistrationCosts({ - duration: 1, - name: debouncedSearchQuery, - rentPrice: registrationData?.rentPrice, - }); - - const state = useMemo(() => { - if (isAvailable) return 'success'; - if (isRegistered || isInvalid) return 'warning'; - return 'rainbow'; - }, [isAvailable, isInvalid, isRegistered]); - - const handlePressContinue = useCallback(() => { - dispatch(startRegistration(accountAddress, `${searchQuery}${ENS_DOMAIN}`)); - Keyboard.dismiss(); - navigate(Routes.ENS_ASSIGN_RECORDS_SHEET); - }, [accountAddress, dispatch, navigate, searchQuery]); - - return ( - - - - - 􀠎 Find your name - - - Search available profile names - - - - - setSearchQuery(normalizeENS(value))} - placeholder="Input placeholder" - state={state} - value={searchQuery} - /> - - {isIdle && ( - - - - - - Minimum 3 characters - - - )} - {isInvalid && ( - - - {registrationData?.hint} - - - )} - {(isAvailable || isRegistered) && ( - - - - - } - space="19px" - > - - - {isRegistered ? ( - - ) : ( - - )} - - - {isRegistered ? ( - - This name was last registered on{' '} - {registrationData?.registrationDate} - - ) : ( - - {registrationCostsDataIsAvailable ? ( - - Estimated total cost of - - {` ${registrationCostsData?.estimatedTotalRegistrationCost?.display} `} - - with current network fees - - ) : ( - - {'Loading network fees...\n'} - - )} - - )} - - - - )} - - - - {isAvailable && ( - - )} - {(isRegistered || isInvalid) && ( - setSearchQuery('')}>􀅉 Clear - )} - - - - - ); -} diff --git a/src/screens/ENSSearchSheet.tsx b/src/screens/ENSSearchSheet.tsx new file mode 100644 index 00000000000..500c2129e62 --- /dev/null +++ b/src/screens/ENSSearchSheet.tsx @@ -0,0 +1,262 @@ +import { useFocusEffect } from '@react-navigation/core'; +import lang from 'i18n-js'; +import React, { useCallback, useMemo, useState } from 'react'; +import { Keyboard } from 'react-native'; +import { Source } from 'react-native-fast-image'; +import { useDebounce } from 'use-debounce'; +import dice from '../assets/dice.png'; +import TintButton from '../components/buttons/TintButton'; +import { + PendingRegistrations, + SearchInput, + SearchResultGradientIndicator, +} from '../components/ens-registration'; +import { SheetActionButton, SheetActionButtonRow } from '../components/sheet'; +import { useNavigation } from '../navigation/Navigation'; +import { + Box, + Divider, + Heading, + Inline, + Inset, + Stack, + Text, +} from '@rainbow-me/design-system'; +import { ENS_DOMAIN, REGISTRATION_MODES } from '@rainbow-me/helpers/ens'; +import { + useENSRegistration, + useENSRegistrationCosts, + useENSRegistrationStepHandler, + useENSSearch, +} from '@rainbow-me/hooks'; +import { ImgixImage } from '@rainbow-me/images'; +import Routes from '@rainbow-me/routes'; +import { colors } from '@rainbow-me/styles'; +import { normalizeENS } from '@rainbow-me/utils'; + +export default function ENSSearchSheet() { + const { navigate } = useNavigation(); + + const topPadding = android ? 29 : 19; + + const { startRegistration, name } = useENSRegistration(); + + const [searchQuery, setSearchQuery] = useState(name?.replace(ENS_DOMAIN, '')); + const [inputValue, setInputValue] = useState(name?.replace(ENS_DOMAIN, '')); + const [debouncedSearchQuery] = useDebounce(searchQuery, 200); + + const { + data: registrationData, + isIdle, + isRegistered, + isLoading, + isInvalid, + isAvailable, + } = useENSSearch({ + name: debouncedSearchQuery, + }); + + const { step } = useENSRegistrationStepHandler(); + const { + data: registrationCostsData, + isSuccess: registrationCostsDataIsAvailable, + } = useENSRegistrationCosts({ + name: debouncedSearchQuery, + rentPrice: registrationData?.rentPrice, + step, + yearsDuration: 1, + }); + + const state = useMemo(() => { + if (isAvailable) return 'success'; + if (isRegistered || isInvalid) return 'warning'; + }, [isAvailable, isInvalid, isRegistered]); + + const handlePressContinue = useCallback(() => { + startRegistration(`${searchQuery}${ENS_DOMAIN}`, REGISTRATION_MODES.CREATE); + Keyboard.dismiss(); + navigate(Routes.ENS_ASSIGN_RECORDS_SHEET); + }, [navigate, searchQuery, startRegistration]); + + useFocusEffect( + useCallback(() => { + debouncedSearchQuery.length >= 3 && + startRegistration( + `${debouncedSearchQuery}${ENS_DOMAIN}`, + REGISTRATION_MODES.CREATE + ); + }, [debouncedSearchQuery, startRegistration]) + ); + + return ( + + + + + + {`􀠎 ${lang.t('profiles.search.header')}`} + + + {lang.t('profiles.search.description')} + + + + + { + setSearchQuery(normalizeENS(value)); + setInputValue(value); + }} + selectionColor={ + isAvailable + ? colors.green + : isRegistered + ? colors.yellowOrange + : colors.appleBlue + } + state={state} + testID="ens-search-input" + value={inputValue} + /> + + {isIdle && ( + + + + + + + {lang.t('profiles.search.3_char_min')} + + + + )} + {isIdle && } + {isInvalid && ( + + + {registrationData?.hint} + + + )} + {(isAvailable || isRegistered) && ( + + + + + } + space="19px" + > + + + {isRegistered ? ( + + ) : ( + + )} + + + {isRegistered ? ( + + {lang.t('profiles.search.registered_on', { + content: registrationData?.registrationDate, + })} + + ) : ( + + {registrationCostsDataIsAvailable ? ( + + {lang.t('profiles.search.estimated_total_cost_1')} + + {` ${registrationCostsData?.estimatedTotalRegistrationCost?.display} `} + + {lang.t('profiles.search.estimated_total_cost_2')} + + ) : ( + + {`${lang.t('profiles.search.loading_fees')}\n`} + + )} + + )} + + + + )} + + + {isAvailable && ( + + )} + {(isRegistered || isInvalid) && ( + { + setSearchQuery(''); + setInputValue(''); + }} + testID="ens-search-clear-button" + > + {lang.t('profiles.search.clear')} + + )} + + + + ); +} diff --git a/src/screens/ExchangeModal.js b/src/screens/ExchangeModal.js index b0c1ce6cd66..5c724a2777d 100644 --- a/src/screens/ExchangeModal.js +++ b/src/screens/ExchangeModal.js @@ -51,7 +51,7 @@ import { } from '@rainbow-me/hooks'; import { loadWallet } from '@rainbow-me/model/wallet'; import { useNavigation } from '@rainbow-me/navigation'; -import { executeRap, getRapEstimationByType } from '@rainbow-me/raps'; +import { executeRap, getSwapRapEstimationByType } from '@rainbow-me/raps'; import { multicallClearState } from '@rainbow-me/redux/multicall'; import { swapClearState, updateSwapTypeDetails } from '@rainbow-me/redux/swap'; import { ETH_ADDRESS, ethUnits } from '@rainbow-me/references'; @@ -254,14 +254,12 @@ export default function ExchangeModal({ ) { return; } - const swapParams = { + const swapParameters = { inputAmount, outputAmount, tradeDetails, }; - const gasLimit = await getRapEstimationByType(type, { - swapParameters: swapParams, - }); + const gasLimit = await getSwapRapEstimationByType(type, swapParameters); if (gasLimit) { updateTxFee(gasLimit); } @@ -422,7 +420,7 @@ export default function ExchangeModal({ outputAmount, tradeDetails, }; - await executeRap(wallet, type, { swapParameters }, callback); + await executeRap(wallet, type, swapParameters, callback); logger.log('[exchange - handle submit] executed rap!'); analytics.track(`Completed ${type}`, { amountInUSD, diff --git a/src/screens/ExpandedAssetSheet.js b/src/screens/ExpandedAssetSheet.js index 6ef14313c65..b58edc19da8 100644 --- a/src/screens/ExpandedAssetSheet.js +++ b/src/screens/ExpandedAssetSheet.js @@ -13,6 +13,7 @@ import { } from '../components/expanded-state'; import { Centered } from '../components/layout'; import { useTheme } from '@rainbow-me/context'; +import { isUnknownOpenSeaENS } from '@rainbow-me/handlers/ens'; import { useAsset, useDimensions } from '@rainbow-me/hooks'; import { useNavigation } from '@rainbow-me/navigation'; import styled from '@rainbow-me/styled-components'; @@ -46,7 +47,12 @@ export default function ExpandedAssetSheet(props) { const { goBack } = useNavigation(); const { params } = useRoute(); const { isDarkMode } = useTheme(); - const selectedAsset = useAsset(params.asset); + + // We want to revalidate (ie. refresh OpenSea metadata) collectibles + // to ensure the user can get the latest metadata of their collectible. + const selectedAsset = useAsset(params.asset, { + revalidateCollectibleInBackground: isUnknownOpenSeaENS(params?.asset), + }); return ( ({ floor_price: { emoji: '📊', @@ -107,6 +129,36 @@ export const explainers = network => ({ networkName: network, }), }, + ens_primary_name: { + extraHeight: -70, + emoji: '❓', + text: ENS_PRIMARY_NAME_EXPLAINER, + title: ENS_PRIMARY_NAME_TITLE, + }, + ens_manager: { + extraHeight: -80, + emoji: '❓', + text: ENS_MANAGER_EXPLAINER, + title: ENS_MANAGER_TITLE, + }, + ens_owner: { + extraHeight: -80, + emoji: '❓', + text: ENS_OWNER_EXPLAINER, + title: ENS_OWNER_TITLE, + }, + ens_resolver: { + extraHeight: -60, + emoji: '❓', + text: ENS_RESOLVER_EXPLAINER, + title: ENS_RESOLVER_TITLE, + }, + ensOnChainDataWarning: { + extraHeight: -30, + emoji: '✋', + text: ENS_ON_CHAIN_DATA_WARNING_EXPLAINER, + title: ENS_ON_CHAIN_DATA_WARNING_TITLE, + }, currentBaseFeeStable: { emoji: '🌞', extraHeight: android ? 40 : 28, diff --git a/src/screens/ExternalLinkWarningSheet.tsx b/src/screens/ExternalLinkWarningSheet.tsx index 5d51b9afa6e..ce0ae0f603b 100644 --- a/src/screens/ExternalLinkWarningSheet.tsx +++ b/src/screens/ExternalLinkWarningSheet.tsx @@ -68,11 +68,11 @@ const ExternalLinkWarningSheet = () => { width: '100%', }} > - {/* @ts-expect-error JavaScript component */} 🧭 diff --git a/src/screens/ProfileScreen.js b/src/screens/ProfileScreen.js index 4c73e88b172..c27218c3702 100644 --- a/src/screens/ProfileScreen.js +++ b/src/screens/ProfileScreen.js @@ -39,6 +39,7 @@ export default function ProfileScreen({ navigation }) { activityListInitialized, isFocused ); + const { isLoadingTransactions: isLoading, sections, diff --git a/src/screens/ProfileSheet.tsx b/src/screens/ProfileSheet.tsx new file mode 100644 index 00000000000..29fec524afe --- /dev/null +++ b/src/screens/ProfileSheet.tsx @@ -0,0 +1,182 @@ +import { useRoute } from '@react-navigation/core'; +import analytics from '@segment/analytics-react-native'; +import React, { createContext, useEffect, useMemo } from 'react'; +import { StatusBar } from 'react-native'; +import RecyclerAssetList2 from '../components/asset-list/RecyclerAssetList2'; +import ProfileSheetHeader from '../components/ens-profile/ProfileSheetHeader'; +import Skeleton from '../components/skeleton/Skeleton'; +import { useTheme } from '@rainbow-me/context'; +import { + AccentColorProvider, + Box, + Column, + Columns, + Inline, + Inset, + Stack, +} from '@rainbow-me/design-system'; +import { maybeSignUri } from '@rainbow-me/handlers/imgix'; +import { + useAccountSettings, + useDimensions, + useENSProfile, + useENSProfileImages, + useENSResolveName, + useExternalWalletSectionsData, + useFirstTransactionTimestamp, + usePersistentDominantColorFromImage, +} from '@rainbow-me/hooks'; +import { sharedCoolModalTopOffset } from '@rainbow-me/navigation/config'; +import Routes from '@rainbow-me/routes'; +import { addressHashedColorIndex } from '@rainbow-me/utils/profileUtils'; + +export const ProfileSheetConfigContext = createContext<{ + enableZoomableImages: boolean; +}>({ + enableZoomableImages: false, +}); + +export default function ProfileSheet() { + const { params, name } = useRoute(); + const { colors } = useTheme(); + const { accountAddress } = useAccountSettings(); + + const { height: deviceHeight } = useDimensions(); + const contentHeight = deviceHeight - sharedCoolModalTopOffset; + + const ensName = params?.address; + const { isSuccess } = useENSProfile(ensName); + const { data: images, isFetched: isImagesFetched } = useENSProfileImages( + ensName + ); + const avatarUrl = images?.avatarUrl; + + const { data: profileAddress } = useENSResolveName(ensName); + + // Prefetch first transaction timestamp + useFirstTransactionTimestamp({ + ensName, + }); + + // Prefetch asset list + const { isSuccess: hasListFetched } = useExternalWalletSectionsData({ + address: profileAddress, + }); + + const colorIndex = useMemo( + () => (profileAddress ? addressHashedColorIndex(profileAddress) : 0), + [profileAddress] + ); + + const { result: dominantColor, state } = usePersistentDominantColorFromImage( + maybeSignUri(avatarUrl || '') || '' + ); + + const wrapperStyle = useMemo(() => ({ height: contentHeight }), [ + contentHeight, + ]); + + const accentColor = + // Set accent color when ENS images have fetched & dominant + // color is not loading. + isImagesFetched && state !== 1 && typeof colorIndex === 'number' + ? dominantColor || + colors.avatarBackgrounds[colorIndex] || + colors.appleBlue + : colors.skeleton; + + const enableZoomableImages = + !params.isPreview && name !== Routes.PROFILE_PREVIEW_SHEET; + + useEffect(() => { + if (profileAddress && accountAddress) { + analytics.track('Viewed profile', { + category: 'profiles', + fromRoute: params.fromRoute, + name: profileAddress !== accountAddress ? ensName : '', + }); + } + }, [params, ensName, profileAddress, accountAddress]); + + return ( + + + + + + + {!isSuccess || !hasListFetched ? ( + + + + + ) : !params.isPreview ? ( + + ) : ( + + )} + + + + + + ); +} + +function AndroidWrapper({ children }: { children: React.ReactElement }) { + return android ? ( + + {children} + + ) : ( + children + ); +} + +function PlaceholderList() { + return ( + + + + + + + + + + + + + + ); +} + +function PlaceholderRow() { + return ( + + + + + + + + + ); +} diff --git a/src/screens/SelectENSSheet.tsx b/src/screens/SelectENSSheet.tsx new file mode 100644 index 00000000000..36d08d0a766 --- /dev/null +++ b/src/screens/SelectENSSheet.tsx @@ -0,0 +1,161 @@ +import { useRoute } from '@react-navigation/core'; +import lang from 'i18n-js'; +import React, { useCallback, useMemo } from 'react'; +import { FlatList } from 'react-native-gesture-handler'; +import ButtonPressAnimation from '../components/animations/ButtonPressAnimation'; +import { Sheet } from '../components/sheet'; +import { + AccentColorProvider, + Bleed, + Box, + Heading, + Inline, + Inset, + Stack, + Text, + useForegroundColor, +} from '@rainbow-me/design-system'; +import { + useAccountENSDomains, + useAccountProfile, + useAccountSettings, +} from '@rainbow-me/hooks'; +import { ImgixImage } from '@rainbow-me/images'; +import { useNavigation } from '@rainbow-me/navigation'; +import { deviceUtils } from '@rainbow-me/utils'; + +export const SelectENSSheetHeight = 400; + +const deviceHeight = deviceUtils.dimensions.height; +const rowHeight = 40; +const maxListHeight = deviceHeight - 220; + +export default function SelectENSSheet() { + const { data: accountENSDomains, isSuccess } = useAccountENSDomains(); + const { accountAddress } = useAccountSettings(); + const { accountENS } = useAccountProfile(); + + const secondary06 = useForegroundColor('secondary06'); + const secondary30 = useForegroundColor('secondary30'); + + const { goBack } = useNavigation(); + const { params } = useRoute(); + + const handleSelectENS = useCallback( + ensName => { + goBack(); + params?.onSelectENS(ensName); + }, + [goBack, params] + ); + + const ownedDomains = useMemo(() => { + const domains = accountENSDomains + ?.filter( + ({ owner, name }) => + owner?.id?.toLowerCase() === accountAddress.toLowerCase() && + accountENS !== name + ) + ?.sort((a, b) => (a.name > b.name ? 1 : -1)); + + const primaryDomain = accountENSDomains?.find( + ({ name }) => accountENS === name + ); + if (primaryDomain) domains?.unshift(primaryDomain); + + return domains; + }, [accountAddress, accountENS, accountENSDomains]); + + let listHeight = (rowHeight + 40) * (ownedDomains?.length || 0); + let scrollEnabled = false; + if (listHeight > maxListHeight) { + listHeight = maxListHeight; + scrollEnabled = true; + } + + const renderItem = useCallback( + ({ item }) => { + return ( + handleSelectENS(item.name)} + scaleTo={0.95} + > + + + + + {item.images.avatarUrl ? ( + + ) : ( + + + 􀉭 + + + )} + + + + {item.name} + + + + + + + ); + }, + [handleSelectENS, secondary06, secondary30] + ); + + return ( + // @ts-expect-error JavaScript component + + + + + {lang.t('profiles.select_ens_name')} + + {isSuccess && ( + + } + as={FlatList} + contentContainerStyle={{ + paddingBottom: 50, + paddingHorizontal: 19, + }} + data={ownedDomains} + height={{ custom: listHeight }} + initialNumToRender={15} + keyExtractor={({ domain }: { domain: string }) => domain} + maxToRenderPerBatch={10} + renderItem={renderItem} + scrollEnabled={scrollEnabled} + showsVerticalScrollIndicator={false} + /> + + )} + + + + ); +} diff --git a/src/screens/SelectUniqueTokenSheet.tsx b/src/screens/SelectUniqueTokenSheet.tsx new file mode 100644 index 00000000000..180dba90b71 --- /dev/null +++ b/src/screens/SelectUniqueTokenSheet.tsx @@ -0,0 +1,59 @@ +import { useNavigation, useRoute } from '@react-navigation/core'; +import React, { useContext, useEffect, useMemo } from 'react'; +import { Animated as RNAnimated } from 'react-native'; +import { useMemoOne } from 'use-memo-one'; +import { + RecyclerAssetListContext, + RecyclerAssetListScrollPositionContext, +} from '../components/asset-list/RecyclerAssetList2/core/Contexts'; +import RawMemoRecyclerAssetList from '../components/asset-list/RecyclerAssetList2/core/RawRecyclerList'; +import { StickyHeaderManager } from '../components/asset-list/RecyclerAssetList2/core/StickyHeaders'; +import useMemoBriefSectionData from '../components/asset-list/RecyclerAssetList2/core/useMemoBriefSectionData'; +import { SheetHandle } from '../components/sheet'; +import { ModalContext } from '../react-native-cool-modals/NativeStackView'; +import { Box } from '@rainbow-me/design-system'; +import { UniqueAsset } from '@rainbow-me/entities'; + +export default function SelectUniqueTokenSheet() { + const { params } = useRoute(); + const { goBack } = useNavigation(); + const { layout } = useContext(ModalContext) || {}; + + useEffect(() => { + setTimeout(() => layout?.(), 300); + }, [layout]); + + const { + memoizedResult: briefSectionsData, + additionalData, + } = useMemoBriefSectionData({ type: 'select-nft' }); + const position = useMemoOne(() => new RNAnimated.Value(0), []); + + const value = useMemo( + () => ({ + additionalData, + onPressUniqueToken: (asset: UniqueAsset) => { + /* @ts-expect-error No types for `param` yet */ + params.onSelect?.(asset); + goBack(); + }, + }), + [additionalData, goBack, params] + ); + + return ( + + + {/* @ts-expect-error JavaScript component */} + + + + + + + + + + + ); +} diff --git a/src/screens/SendConfirmationSheet.js b/src/screens/SendConfirmationSheet.js index 87d2d465a42..9ce1d90b55f 100644 --- a/src/screens/SendConfirmationSheet.js +++ b/src/screens/SendConfirmationSheet.js @@ -24,18 +24,25 @@ import { addressHashedColorIndex, addressHashedEmoji, } from '../utils/profileUtils'; +import useExperimentalFlag, { + PROFILES, +} from '@rainbow-me/config/experimentalHooks'; import { removeFirstEmojiFromString, returnStringFirstEmoji, } from '@rainbow-me/helpers/emojiHandler'; import { convertAmountToNativeDisplay } from '@rainbow-me/helpers/utilities'; -import { isValidDomainFormat } from '@rainbow-me/helpers/validators'; +import { + isENSAddressFormat, + isValidDomainFormat, +} from '@rainbow-me/helpers/validators'; import { useAccountSettings, useAccountTransactions, useColorForAsset, useContacts, useDimensions, + useENSProfileImages, useUserAccounts, useWallets, } from '@rainbow-me/hooks'; @@ -172,6 +179,7 @@ export default function SendConfirmationSheet() { const [isAuthorizing, setIsAuthorizing] = useState(false); const insets = useSafeArea(); const { contacts } = useContacts(); + const profilesEnabled = useExperimentalFlag(PROFILES); useEffect(() => { android && Keyboard.dismiss(); @@ -231,8 +239,8 @@ export default function SendConfirmationSheet() { }, [isSendingToUserAccount, network, toAddress, transactions]); const contact = useMemo(() => { - return get(contacts, `${[toLower(to)]}`); - }, [contacts, to]); + return get(contacts, `${[toLower(toAddress)]}`); + }, [contacts, toAddress]); const [checkboxes, setCheckboxes] = useState([ { @@ -333,7 +341,6 @@ export default function SendConfirmationSheet() { const avatarValue = returnStringFirstEmoji(existingAccount?.label) || - contact?.nickname || addressHashedEmoji(toAddress); const avatarColor = @@ -349,7 +356,13 @@ export default function SendConfirmationSheet() { realSheetHeight -= 80; } - const accountImage = existingAccount?.image; + const { data: images } = useENSProfileImages(to, { + enabled: isENSAddressFormat(to), + }); + + const accountImage = profilesEnabled + ? images?.avatarUrl || existingAccount?.image + : existingAccount?.image; const contentHeight = realSheetHeight - (isL2 ? 50 : 30); return ( diff --git a/src/screens/SendSheet.js b/src/screens/SendSheet.js index 91ee4f9294f..e3b46b670e7 100644 --- a/src/screens/SendSheet.js +++ b/src/screens/SendSheet.js @@ -17,6 +17,8 @@ import { SendHeader, } from '../components/send'; import { SheetActionButton } from '../components/sheet'; +import { prefetchENSProfileImages } from '../hooks/useENSProfileImages'; +import { PROFILES, useExperimentalFlag } from '@rainbow-me/config'; import { AssetTypes } from '@rainbow-me/entities'; import { isL2Asset, isNativeAsset } from '@rainbow-me/handlers/assets'; import { debouncedFetchSuggestions } from '@rainbow-me/handlers/ens'; @@ -31,7 +33,10 @@ import { } from '@rainbow-me/handlers/web3'; import isNativeStackAvailable from '@rainbow-me/helpers/isNativeStackAvailable'; import Network from '@rainbow-me/helpers/networkTypes'; -import { checkIsValidAddressOrDomain } from '@rainbow-me/helpers/validators'; +import { + checkIsValidAddressOrDomain, + isENSAddressFormat, +} from '@rainbow-me/helpers/validators'; import { useAccountSettings, useCoinListEditOptions, @@ -110,6 +115,7 @@ export default function SendSheet(props) { updateTxFee, } = useGas(); const recipientFieldRef = useRef(); + const profilesEnabled = useExperimentalFlag(PROFILES); const { contacts, onRemoveContact, filteredContacts } = useContacts(); const { userAccounts, watchedAccounts } = useUserAccounts(); @@ -137,6 +143,7 @@ export default function SendSheet(props) { const recipientOverride = params?.address; const nativeAmountOverride = params?.nativeAmount; const [recipient, setRecipient] = useState(''); + const [nickname, setNickname] = useState(''); const [selected, setSelected] = useState({}); const { maxInputBalance, updateMaxInputBalance } = useMaxInputBalance(); @@ -682,6 +689,7 @@ export default function SendSheet(props) { }); return; } + navigate(Routes.SEND_CONFIRMATION_SHEET, { amountDetails: amountDetails, asset: selected, @@ -712,10 +720,17 @@ export default function SendSheet(props) { sendUpdateSelected({}); }, [sendUpdateSelected]); - const onChangeInput = useCallback(event => { - setCurrentInput(event); - setRecipient(event); - }, []); + const onChangeInput = useCallback( + event => { + setCurrentInput(event); + setRecipient(event); + setNickname(event); + if (profilesEnabled && isENSAddressFormat(event)) { + prefetchENSProfileImages(event); + } + }, + [profilesEnabled] + ); useEffect(() => { updateDefaultGasLimit(); @@ -740,7 +755,12 @@ export default function SendSheet(props) { const [ensSuggestions, setEnsSuggestions] = useState([]); useEffect(() => { if (network === Network.mainnet && !recipientOverride) { - debouncedFetchSuggestions(recipient, setEnsSuggestions); + debouncedFetchSuggestions( + recipient, + setEnsSuggestions, + undefined, + profilesEnabled + ); } else { setEnsSuggestions([]); } @@ -750,6 +770,7 @@ export default function SendSheet(props) { recipientOverride, setEnsSuggestions, watchedAccounts, + profilesEnabled, ]); useEffect(() => { @@ -805,14 +826,21 @@ export default function SendSheet(props) { network, ]); + const sendContactListDataKey = useMemo( + () => `${ensSuggestions?.[0]?.address || '_'}`, + [ensSuggestions] + ); + return ( {ios && } { + setRecipient(recipient); + setNickname(nickname); + }} removeContact={onRemoveContact} userAccounts={userAccounts} watchedAccounts={watchedAccounts} diff --git a/src/screens/SpeedUpAndCancelSheet.js b/src/screens/SpeedUpAndCancelSheet.js index 704a06e991c..e83bd2eb8b0 100644 --- a/src/screens/SpeedUpAndCancelSheet.js +++ b/src/screens/SpeedUpAndCancelSheet.js @@ -128,8 +128,9 @@ export default function SpeedUpAndCancelSheet() { const calculatingGasLimit = useRef(false); const speedUrgentSelected = useRef(false); const { - params: { type, tx }, + params: { type, tx, accentColor, onSendTransactionCallback }, } = useRoute(); + const [ready, setReady] = useState(false); const [txType, setTxType] = useState(); const [minGasPrice, setMinGasPrice] = useState( @@ -244,6 +245,7 @@ export default function SpeedUpAndCancelSheet() { provider: currentProvider, transaction: fasterTxPayload, }); + onSendTransactionCallback?.(hash); const updatedTx = { ...tx }; // Update the hash on the copy of the original tx updatedTx.hash = hash; @@ -268,6 +270,7 @@ export default function SpeedUpAndCancelSheet() { getNewTransactionGasParams, goBack, nonce, + onSendTransactionCallback, to, tx, value, @@ -546,7 +549,7 @@ export default function SpeedUpAndCancelSheet() { weight="bold" /> walletReady ); @@ -86,7 +87,6 @@ export default function WalletScreen() { } = useWalletSectionsData(); const dispatch = useDispatch(); - const profilesEnabled = useExperimentalFlag(PROFILES); const { addressSocket, assetsSocket } = useSelector( ({ explorer: { addressSocket, assetsSocket } }) => ({ @@ -95,6 +95,8 @@ export default function WalletScreen() { }) ); + const profilesEnabled = useExperimentalFlag(PROFILES); + useEffect(() => { const fetchAndResetFetchSavings = async () => { await refetchSavings(); @@ -139,6 +141,12 @@ export default function WalletScreen() { userAccounts, ]); + useEffect(() => { + if (profilesEnabled) { + trackENSProfile(); + } + }, [profilesEnabled, trackENSProfile]); + useEffect(() => { if ( !isEmpty(portfolios) && @@ -162,10 +170,22 @@ export default function WalletScreen() { useEffect(() => { if (walletReady && assetsSocket) { + loadAccountLateData(); loadGlobalLateData(); initializeDiscoverData(); } - }, [assetsSocket, initializeDiscoverData, loadGlobalLateData, walletReady]); + }, [ + assetsSocket, + initializeDiscoverData, + loadAccountLateData, + loadGlobalLateData, + walletReady, + ]); + + useEffect(() => { + if (walletReady) updateWalletENSAvatars(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [walletReady]); // Show the exchange fab only for supported networks // (mainnet & rinkeby) @@ -174,9 +194,8 @@ export default function WalletScreen() { [ !!get(networkInfo[network], 'exchange_enabled') && ExchangeFab, SendFab, - profilesEnabled ? RegisterEnsFab : null, ].filter(e => !!e), - [network, profilesEnabled] + [network] ); const isLoadingAssets = useSelector(state => state.data.isLoadingAssets); diff --git a/src/styles/colors.ts b/src/styles/colors.ts index 054db52844d..faed2ab4369 100644 --- a/src/styles/colors.ts +++ b/src/styles/colors.ts @@ -11,7 +11,7 @@ const buildRgba = memoFn( ); const darkModeColors = { - appleBlue: '#0E76FD', + appleBlue: '#1F87FF', black: '#FFFFFF', blueGreyDark: '#E0E8FF', blueGreyDark04: '#222326', @@ -34,6 +34,7 @@ const darkModeColors = { lightOrange: '#FFA64D', offWhite: '#1F222A', offWhite80: '#1C1F27', + placeholder: 'rgba(224, 232, 255, 0.4)', rowDivider: 'rgba(60, 66, 82, 0.075)', rowDividerExtraLight: 'rgba(60, 66, 82, 0.0375)', rowDividerFaint: 'rgba(60, 66, 82, 0.025)', @@ -186,6 +187,10 @@ const getColorsByTheme = (darkMode?: boolean) => { }; let gradients = { + appleBlueTintToAppleBlue: ['#15B1FE', base.appleBlue], + blueToGreen: ['#4764F7', '#23D67F'], + checkmarkAnimation: ['#1FC24A10', '#1FC24A10', '#1FC24A00'], + ens: ['#513eff', '#3e80ff'], lighterGrey: [buildRgba('#ECF1F5', 0.15), buildRgba('#DFE4EB', 0.5)], lightestGrey: ['#FFFFFF', '#F2F4F7'], lightestGreyReverse: ['#F2F4F7', '#FFFFFF'], @@ -202,10 +207,17 @@ const getColorsByTheme = (darkMode?: boolean) => { sendBackground: ['#FAFAFA00', '#FAFAFAFF'], success: ['#FAFF00', '#2CCC00'], successTint: ['#FFFFF0', '#FCFEFB'], - transparentToGreen: ['transparent', buildRgba(base.green, 0.06)], - transparentToLightGrey: ['transparent', buildRgba(base.blueGreyDark, 0.06)], + transparentToAppleBlue: [ + buildRgba(base.appleBlue, 0.02), + buildRgba(base.appleBlue, 0.06), + ], + transparentToGreen: [buildRgba(base.green, 0), buildRgba(base.green, 0.06)], + transparentToLightGrey: [ + buildRgba(base.blueGreyDark, 0), + buildRgba(base.blueGreyDark, 0.06), + ], transparentToLightOrange: [ - 'transparent', + buildRgba(base.lightOrange, 0), buildRgba(base.lightOrange, 0.06), ], vividRainbow: ['#FFB114', '#FF54BB', '#00F0FF'], @@ -283,6 +295,10 @@ const getColorsByTheme = (darkMode?: boolean) => { }; gradients = { + appleBlueTintToAppleBlue: ['#2FC3FF', base.appleBlue], + blueToGreen: ['#4764F7', '#23D67F'], + checkmarkAnimation: ['#1FC24A10', '#1FC24A10', '#1FC24A00'], + ens: ['#513eff', '#3e80ff'], lighterGrey: [buildRgba('#1F222A', 0.8), buildRgba('#1F222A', 0.6)], lightestGrey: [buildRgba('#1F222A', 0.8), buildRgba('#1F222A', 0.3)], lightestGreyReverse: [ @@ -291,8 +307,8 @@ const getColorsByTheme = (darkMode?: boolean) => { ], lightGrey: ['#1F222A', buildRgba('#1F222A', 0.8)], lightGreyTransparent: [ + buildRgba(base.blueGreyDark, 0.02), buildRgba(base.blueGreyDark, 0.06), - buildRgba(base.blueGreyDark, 0.025), ], lightGreyWhite: [buildRgba('#F0F2F5', 0.05), buildRgba('#FFFFFF', 0.01)], offWhite: ['#1F222A', '#1F222A'], @@ -302,13 +318,20 @@ const getColorsByTheme = (darkMode?: boolean) => { sendBackground: ['#12131A00', '#12131AFF'], success: ['#FAFF00', '#2CCC00'], successTint: ['#202118', '#141E18'], - transparentToGreen: ['transparent', buildRgba(base.green, 0.06)], + transparentToAppleBlue: [ + buildRgba(base.appleBlue, 0.02), + buildRgba(base.appleBlue, 0.06), + ], + transparentToGreen: [ + buildRgba(base.green, 0), + buildRgba(base.green, 0.06), + ], transparentToLightGrey: [ - 'transparent', + buildRgba(base.blueGreyDark, 0), buildRgba(base.blueGreyDark, 0.06), ], transparentToLightOrange: [ - 'transparent', + buildRgba(base.lightOrange, 0), buildRgba(base.lightOrange, 0.06), ], vividRainbow: ['#FFB114', '#FF54BB', '#00F0FF'], diff --git a/src/utils/__tests__/validateENS.test.ts b/src/utils/__tests__/ens.test.ts similarity index 98% rename from src/utils/__tests__/validateENS.test.ts rename to src/utils/__tests__/ens.test.ts index e3d81828353..ab69864756b 100644 --- a/src/utils/__tests__/validateENS.test.ts +++ b/src/utils/__tests__/ens.test.ts @@ -1,4 +1,4 @@ -import validateENS from '../validateENS'; +import { validateENS } from '../ens'; describe('valid names', () => { it('domain', () => { diff --git a/src/utils/branch.ts b/src/utils/branch.ts index aeb54763c4c..e1cb17401e3 100644 --- a/src/utils/branch.ts +++ b/src/utils/branch.ts @@ -1,6 +1,5 @@ // @ts-expect-error ts-migrate(2305) FIXME: Could not find a declaration file for module 'pako... Remove this comment to see the full error message import pako from 'pako'; -// @ts-expect-error ts-migrate(2305) FIXME: Could not find a declaration file for module 'qs'. import qs from 'qs'; import branch from 'react-native-branch'; // @ts-expect-error ts-migrate(2305) FIXME: Module '"react-native-dotenv"' has no exported mem... Remove this comment to see the full error message @@ -72,7 +71,7 @@ export const branchListener = (handleOpenLinkingURL: (url: any) => void) => const decodeBranchUrl = (source: string) => { const query = source.split('?')[1]; const queryParam = qs.parse(query)['_branch_referrer']; - const base64Url = decodeURIComponent(queryParam); + const base64Url = decodeURIComponent(queryParam as string); const ascii = Buffer.from(base64Url, 'base64'); const originalUniversalUrl = pako.inflate(ascii, { to: 'string' }); diff --git a/src/utils/contenthash.ts b/src/utils/contenthash.ts new file mode 100644 index 00000000000..a7002480965 --- /dev/null +++ b/src/utils/contenthash.ts @@ -0,0 +1,80 @@ +import contentHash from '@ensdomains/content-hash'; +import { isHexString } from '@rainbow-me/handlers/web3'; + +const supportedCodecs = [ + 'ipns-ns', + 'ipfs-ns', + 'swarm-ns', + 'onion', + 'onion3', + 'skynet-ns', + 'arweave-ns', +]; + +function matchProtocol(text: string) { + return ( + text.match(/^(ipfs|sia|ipns|bzz|onion|onion3|arweave):\/\/(.*)/) || + text.match(/\/(ipfs)\/(.*)/) || + text.match(/\/(ipns)\/(.*)/) + ); +} + +export function encodeContenthash(text: string) { + let content, contentType; + let encoded = ''; + let error; + if (text) { + let matched = matchProtocol(text); + if (matched) { + contentType = matched[1]; + content = matched[2]; + try { + if (contentType === 'ipfs') { + if (content?.length >= 4) { + encoded = '0x' + contentHash.encode('ipfs-ns', content); + } + } else if (contentType === 'ipns') { + encoded = '0x' + contentHash.encode('ipns-ns', content); + } else if (contentType === 'bzz') { + if (content?.length >= 4) { + encoded = '0x' + contentHash.fromSwarm(content); + } + } else if (contentType === 'onion') { + if (content?.length === 16) { + encoded = '0x' + contentHash.encode('onion', content); + } + } else if (contentType === 'onion3') { + if (content?.length === 56) { + encoded = '0x' + contentHash.encode('onion3', content); + } + } else if (contentType === 'sia') { + if (content?.length === 46) { + encoded = '0x' + contentHash.encode('skynet-ns', content); + } + } else if (contentType === 'arweave') { + if (content?.length === 43) { + encoded = '0x' + contentHash.encode('arweave-ns', content); + } + } + } catch (err) { + const errorMessage = 'Error encoding content hash'; + error = errorMessage; + } + } + } + return { encoded, error }; +} + +export function isValidContenthash(encoded: string) { + try { + const codec = contentHash.getCodec(encoded); + return isHexString(encoded) && supportedCodecs.includes(codec); + } catch (e) { + return false; + } +} + +export default { + encodeContenthash, + isValidContenthash, +}; diff --git a/src/utils/doesWalletsContainAddress.ts b/src/utils/doesWalletsContainAddress.ts new file mode 100644 index 00000000000..ea25e8ed04a --- /dev/null +++ b/src/utils/doesWalletsContainAddress.ts @@ -0,0 +1,23 @@ +import { AllRainbowWallets } from '@rainbow-me/model/wallet'; + +export default function doesWalletsContainAddress({ + address, + wallets, +}: { + address: string; + wallets: AllRainbowWallets; +}) { + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < Object.keys(wallets).length; i++) { + const key = Object.keys(wallets)[i]; + const someWallet = wallets[key]; + const found = someWallet.addresses.find( + (account: any) => account.visible && account.address !== address + ); + + if (found) { + return { key, wallet: found }; + } + } + return undefined; +} diff --git a/src/utils/ens.ts b/src/utils/ens.ts new file mode 100644 index 00000000000..072eed425dd --- /dev/null +++ b/src/utils/ens.ts @@ -0,0 +1,159 @@ +import uts46 from 'idna-uts46-hx'; +import { UniqueAsset } from '@rainbow-me/entities'; + +const supportedTLDs = ['eth']; + +const ERROR_CODES = { + INVALID_DOMAIN: 'invalid-domain', + INVALID_DOMAIN_NAME: 'invalid-domain-name', + INVALID_LENGTH: 'invalid-length', + INVALID_SUBDOMAIN_NAME: 'invalid-subdomain-name', + INVALID_TLD: 'invalid-tld', + SUBDOMAINS_NOT_SUPPORTED: 'subdomains-not-supported', +} as const; + +/** + * @description Gets the ENS NFT `avatarUrl` from the record `avatar` + */ +export function getENSNFTAvatarUrl( + uniqueTokens: UniqueAsset[], + avatar?: string +) { + let avatarUrl; + if (avatar) { + const isNFTAvatar = isENSNFTRecord(avatar); + if (isNFTAvatar) { + const { contractAddress, tokenId } = parseENSNFTRecord(avatar); + const uniqueToken = uniqueTokens.find( + token => + token.asset_contract.address?.toLowerCase() === + contractAddress.toLowerCase() && token.id === tokenId + ); + if (uniqueToken?.image_url) { + avatarUrl = uniqueToken?.image_url; + } else if (uniqueToken?.image_thumbnail_url) { + avatarUrl = uniqueToken?.image_thumbnail_url; + } + } else if ( + avatar.startsWith('http') || + (avatar.startsWith('/') && !avatar.match(/^\/(ipfs|ipns)/)) + ) { + avatarUrl = avatar; + } + } + return avatarUrl; +} + +export function isENSNFTRecord(avatar: string) { + return avatar.includes('eip155:1'); +} + +export function normalizeENS(name: string) { + try { + return uts46.toUnicode(name, { useStd3ASCII: true }); + } catch (err) { + return name; + } +} + +/** + * @description Converts the ENS NFT record string to a unique token metadata object + */ +export function parseENSNFTRecord(record: string) { + const [standard, contractAddress, tokenId] = record + .replace('eip155:1/', '') + .split(/[:/]+/); + return { + contractAddress, + standard, + tokenId, + }; +} + +/** + * @description Converts an unique token/NFT to a format that is compatible with + * ENS NFT images + */ +export function stringifyENSNFTRecord({ + contractAddress, + tokenId, + standard, +}: { + contractAddress: string; + tokenId: string; + standard: string; +}) { + return `eip155:1/${standard.toLowerCase()}:${contractAddress}/${tokenId}`; +} + +/** + * @description Checks if an ENS name is valid + */ +export function validateENS( + domain: string, + { includeSubdomains = true } = {} +): { + valid: boolean; + hint?: string; + code?: typeof ERROR_CODES[keyof typeof ERROR_CODES]; +} { + const splitDomain = domain.split('.'); + + if (splitDomain.length < 2) { + return { + code: ERROR_CODES.INVALID_DOMAIN, + hint: 'This is an invalid domain', + valid: false, + }; + } + + const [tld, domainName, subDomainName] = splitDomain.reverse(); + + if (!supportedTLDs.includes(tld)) { + return { + code: ERROR_CODES.INVALID_TLD, + hint: 'This TLD is not supported', + valid: false, + }; + } + + if (!includeSubdomains && subDomainName) { + return { + code: ERROR_CODES.SUBDOMAINS_NOT_SUPPORTED, + hint: 'Subdomains are not supported', + valid: false, + }; + } + + if (domainName.length < 3) { + return { + code: ERROR_CODES.INVALID_LENGTH, + hint: 'Your name must be at least 3 characters', + valid: false, + }; + } + + try { + uts46.toUnicode(domainName, { useStd3ASCII: true }); + } catch (err) { + return { + code: ERROR_CODES.INVALID_DOMAIN_NAME, + hint: 'Your name cannot include special characters', + valid: false, + }; + } + + if (subDomainName) { + try { + uts46.toUnicode(subDomainName, { useStd3ASCII: true }); + } catch (err) { + return { + code: ERROR_CODES.INVALID_SUBDOMAIN_NAME, + hint: 'Your subdomain cannot include special characters', + valid: false, + }; + } + } + + return { valid: true }; +} diff --git a/src/utils/ethereumUtils.ts b/src/utils/ethereumUtils.ts index 61e15a3f4c3..824af6d98f8 100644 --- a/src/utils/ethereumUtils.ts +++ b/src/utils/ethereumUtils.ts @@ -326,7 +326,7 @@ const getChainIdFromNetwork = (network: Network): number => { * @desc get etherscan host from network string * @param {String} network */ -function getEtherscanHostForNetwork(network: Network): string { +function getEtherscanHostForNetwork(network?: Network): string { const base_host = 'etherscan.io'; if (network === Network.optimism) { return OPTIMISM_BLOCK_EXPLORER_URL; @@ -334,7 +334,7 @@ function getEtherscanHostForNetwork(network: Network): string { return POLYGON_BLOCK_EXPLORER_URL; } else if (network === Network.arbitrum) { return ARBITRUM_BLOCK_EXPLORER_URL; - } else if (isTestnetNetwork(network)) { + } else if (network && isTestnetNetwork(network)) { return `${network}.${base_host}`; } else { return base_host; @@ -405,6 +405,21 @@ const hasPreviousTransactions = ( }); }; +/** + * @desc Fetches the address' first transaction timestamp (in ms) + * @param {String} address + * @return {Promise} + */ +export const getFirstTransactionTimestamp = async ( + address: EthereumAddress +): Promise => { + const url = `https://api.etherscan.io/api?module=account&action=txlist&address=${address}&startblock=0&endblock=99999999&sort=asc&page=1&offset=1&apikey=${ETHERSCAN_API_KEY}`; + const response = await fetch(url); + const parsedResponse = await response.json(); + const timestamp = parsedResponse.result[0]?.timeStamp; + return timestamp ? timestamp * 1000 : undefined; +}; + const checkIfUrlIsAScam = async (url: string) => { try { const { hostname } = new URL(url); @@ -496,7 +511,7 @@ function getBlockExplorer(network: Network) { function openAddressInBlockExplorer( address: EthereumAddress, - network: Network + network?: Network ) { const etherscanHost = getEtherscanHostForNetwork(network); Linking.openURL(`https://${etherscanHost}/address/${address}`); diff --git a/src/utils/getDominantColorFromImage.ts b/src/utils/getDominantColorFromImage.ts index 2addc94bf76..36fa04aa939 100644 --- a/src/utils/getDominantColorFromImage.ts +++ b/src/utils/getDominantColorFromImage.ts @@ -2,14 +2,19 @@ import c from 'chroma-js'; import makeColorMoreChill from 'make-color-more-chill'; // @ts-expect-error ts-migrate(2305) FIXME: Module '"react-native-dotenv"' has no exported mem... Remove this comment to see the full error message import { IS_TESTING } from 'react-native-dotenv'; -import Palette from 'react-native-palette-full'; +import Palette, { IPalette } from 'react-native-palette-full'; export default async function getDominantColorFromImage( imageUrl: string, colorToMeasureAgainst: string ) { if (IS_TESTING === 'true') return undefined; - let colors = await Palette.getNamedSwatchesFromUrl(imageUrl); + let colors: IPalette; + if (/^http/.test(imageUrl)) { + colors = await Palette.getNamedSwatchesFromUrl(imageUrl); + } else { + colors = await Palette.getNamedSwatches(imageUrl); + } // react-native-palette keys for Android are not the same as iOS so we fix here if (android) { diff --git a/src/utils/getLowResUrl.ts b/src/utils/getLowResUrl.ts index 033b5fded65..cb29e1cb48f 100644 --- a/src/utils/getLowResUrl.ts +++ b/src/utils/getLowResUrl.ts @@ -5,8 +5,19 @@ import { imageToPng } from '@rainbow-me/handlers/imgix'; export const GOOGLE_USER_CONTENT_URL = 'https://lh3.googleusercontent.com/'; const size = Math.floor((Math.ceil(CardSize) * PixelRatio.get()) / 3); +const isValidCDNUrl = (url: string) => { + const splitUrl = url?.split('/'); + const urlTail = splitUrl[splitUrl.length - 1] || ''; + + // check if the url does not already contain a modifier (e.g. `...=s128`) + if (urlTail.includes('=')) return false; + + return url?.startsWith?.(GOOGLE_USER_CONTENT_URL); +}; + export const getLowResUrl = (url: string) => { - if (url?.startsWith?.(GOOGLE_USER_CONTENT_URL)) { + // Check if it is from the given CDN. + if (isValidCDNUrl(url)) { return `${url}=w${size}`; } return imageToPng(url, size); diff --git a/src/utils/index.ts b/src/utils/index.ts index eb8ebda4423..f8d466b252a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -8,10 +8,20 @@ export { default as AllowancesCache } from './allowancesCache'; export { default as buildRainbowUrl } from './buildRainbowUrl'; export { default as TokensListenedCache } from './tokensListenedCache'; export { default as checkTokenIsScam } from './checkTokenIsScam'; +export { default as contenthash } from './contenthash'; export { default as deviceUtils } from './deviceUtils'; export { default as profileUtils } from './profileUtils'; +export { default as doesWalletsContainAddress } from './doesWalletsContainAddress'; export { default as dimensionsPropType } from './dimensionsPropType'; export { default as directionPropType } from './directionPropType'; +export { + getENSNFTAvatarUrl, + isENSNFTRecord, + normalizeENS, + parseENSNFTRecord, + stringifyENSNFTRecord, + validateENS, +} from './ens'; export { default as ethereumUtils } from './ethereumUtils'; export { default as formatURLForDisplay } from './formatURLForDisplay'; export { default as gasUtils } from './gas'; @@ -24,11 +34,11 @@ export { default as isETH } from './isETH'; export { default as isLowerCaseMatch } from './isLowerCaseMatch'; export { default as isNewValueForObjectPaths } from './isNewValueForObjectPaths'; export { default as isNewValueForPath } from './isNewValueForPath'; +export { default as labelhash } from './labelhash'; export { default as logger } from './logger'; export { default as magicMemo } from './magicMemo'; export { default as measureText } from './measureText'; export { default as neverRerender } from './neverRerender'; -export { default as normalizeENS } from './normalizeENS'; export { default as parseObjectToUrlQueryString } from './parseObjectToUrlQueryString'; export { default as parseQueryParams } from './parseQueryParams'; export { default as promiseUtils } from './promise'; @@ -46,7 +56,6 @@ export { removeLeadingZeros, sanitizeSeedPhrase, } from './formatters'; -export { default as validateENS } from './validateENS'; export { default as watchingAlert } from './watchingAlert'; export { default as withSpeed } from './withSpeed'; export { default as CoinIcon } from './CoinIcons/CoinIcon'; diff --git a/src/utils/isSVG.ts b/src/utils/isSVG.ts new file mode 100644 index 00000000000..6c8a0c1309c --- /dev/null +++ b/src/utils/isSVG.ts @@ -0,0 +1,14 @@ +import isSupportedUriExtension from '@rainbow-me/helpers/isSupportedUriExtension'; + +const svgRegexList = [ + new RegExp( + /https:\/\/metadata.ens.domains\/\w+\/0x[0-9a-fA-F]*\/(0x)?[0-9a-fA-f]+\/image/ + ), +]; + +export default function isSVG(url: string) { + if (svgRegexList.some(regex => regex.test(url))) { + return true; + } + return isSupportedUriExtension(url, ['.svg']); +} diff --git a/src/utils/labelhash.ts b/src/utils/labelhash.ts new file mode 100644 index 00000000000..cefbf96ae02 --- /dev/null +++ b/src/utils/labelhash.ts @@ -0,0 +1,51 @@ +import { keccak256, toUtf8Bytes } from 'ethers/lib/utils'; +import { normalizeENS } from './ens'; + +export function encodeLabelhash(hash: string) { + if (!hash.startsWith('0x')) { + throw new Error('Expected label hash to start with 0x'); + } + + if (hash.length !== 66) { + throw new Error('Expected label hash to have a length of 66'); + } + + return `[${hash.slice(2)}]`; +} + +export function decodeLabelhash(hash: string) { + if (!(hash.startsWith('[') && hash.endsWith(']'))) { + throw Error( + 'Expected encoded labelhash to start and end with square brackets' + ); + } + + if (hash.length !== 66) { + throw Error('Expected encoded labelhash to have a length of 66'); + } + + return `${hash.slice(1, -1)}`; +} + +export function isEncodedLabelhash(hash: string) { + return hash.startsWith('[') && hash.endsWith(']') && hash.length === 66; +} + +export function isDecrypted(name: string) { + const nameArray = name.split('.'); + const decrypted = nameArray.reduce((acc, label) => { + if (acc === false) return false; + return isEncodedLabelhash(label) ? false : true; + }, true); + + return decrypted; +} + +export default function labelhash(unnormalisedLabelOrLabelhash: string) { + if (unnormalisedLabelOrLabelhash === '[root]') { + return ''; + } + return isEncodedLabelhash(unnormalisedLabelOrLabelhash) + ? '0x' + decodeLabelhash(unnormalisedLabelOrLabelhash) + : keccak256(toUtf8Bytes(normalizeENS(unnormalisedLabelOrLabelhash))); +} diff --git a/src/utils/normalizeENS.ts b/src/utils/normalizeENS.ts deleted file mode 100644 index dc72e272d5f..00000000000 --- a/src/utils/normalizeENS.ts +++ /dev/null @@ -1,10 +0,0 @@ -// ENS string normalization taken from https://github.com/ensdomains/eth-ens-namehash/blob/master/index.js -import uts46 from 'idna-uts46-hx'; - -export default function normalizeENS(name: string) { - try { - return uts46.toUnicode(name, { useStd3ASCII: true }); - } catch (err) { - return name; - } -} diff --git a/src/utils/profileUtils.ts b/src/utils/profileUtils.ts index 1aa28eedaa7..6800ea26cc3 100644 --- a/src/utils/profileUtils.ts +++ b/src/utils/profileUtils.ts @@ -1,8 +1,8 @@ /* eslint-disable sort-keys-fix/sort-keys-fix */ -import { Provider } from '@ethersproject/providers'; import colors from '../styles/colors'; import { EthereumAddress } from '@rainbow-me/entities'; +import { fetchReverseRecord } from '@rainbow-me/handlers/ens'; // avatars groups emojis with their respective color backgrounds in the `avatarBackgrounds` object in colors.js export const avatars = [ @@ -117,13 +117,14 @@ export function isEthAddress(address: string | null) { return address?.match(/^(0x)?[0-9a-fA-F]{40}$/); } -export async function lookupAddressWithRetry( - web3Provider: Provider, - address: EthereumAddress -) { +export function isValidImagePath(path: string | null) { + return path !== '~undefined'; +} + +export async function fetchReverseRecordWithRetry(address: EthereumAddress) { for (let i = 0; i < 3; i++) { try { - return await web3Provider.lookupAddress(address); + return await fetchReverseRecord(address); // eslint-disable-next-line no-empty } catch {} } @@ -141,7 +142,8 @@ export default { getOldAvatarColorToAvatarBackgroundIndex, getNextEmojiWithColor, hashCode, - lookupAddressWithRetry, + fetchReverseRecordWithRetry, popularEmojis, isEthAddress, + isValidImagePath, }; diff --git a/src/utils/validateENS.ts b/src/utils/validateENS.ts deleted file mode 100644 index 2856d70249a..00000000000 --- a/src/utils/validateENS.ts +++ /dev/null @@ -1,81 +0,0 @@ -import uts46 from 'idna-uts46-hx'; - -const supportedTLDs = ['eth']; - -const ERROR_CODES = { - INVALID_DOMAIN: 'invalid-domain', - INVALID_DOMAIN_NAME: 'invalid-domain-name', - INVALID_LENGTH: 'invalid-length', - INVALID_SUBDOMAIN_NAME: 'invalid-subdomain-name', - INVALID_TLD: 'invalid-tld', - SUBDOMAINS_NOT_SUPPORTED: 'subdomains-not-supported', -} as const; - -export default function validateENS( - domain: string, - { includeSubdomains = true } = {} -): { - valid: boolean; - hint?: string; - code?: typeof ERROR_CODES[keyof typeof ERROR_CODES]; -} { - const splitDomain = domain.split('.'); - - if (splitDomain.length < 2) { - return { - code: ERROR_CODES.INVALID_DOMAIN, - hint: 'This is an invalid domain', - valid: false, - }; - } - - const [tld, domainName, subDomainName] = splitDomain.reverse(); - - if (!supportedTLDs.includes(tld)) { - return { - code: ERROR_CODES.INVALID_TLD, - hint: 'This TLD is not supported', - valid: false, - }; - } - - if (!includeSubdomains && subDomainName) { - return { - code: ERROR_CODES.SUBDOMAINS_NOT_SUPPORTED, - hint: 'Subdomains are not supported', - valid: false, - }; - } - - if (domainName.length < 3) { - return { - code: ERROR_CODES.INVALID_LENGTH, - hint: 'Your name must be at least 3 characters', - valid: false, - }; - } - - try { - uts46.toUnicode(domainName, { useStd3ASCII: true }); - } catch (err) { - return { - code: ERROR_CODES.INVALID_DOMAIN_NAME, - hint: 'Your name cannot include special characters', - valid: false, - }; - } - - if (subDomainName) { - try { - uts46.toUnicode(subDomainName, { useStd3ASCII: true }); - } catch (err) { - return { - code: ERROR_CODES.INVALID_SUBDOMAIN_NAME, - hint: 'Your subdomain cannot include special characters', - valid: false, - }; - } - } - - return { valid: true }; -} diff --git a/tsconfig.json b/tsconfig.json index 05c39974368..e61cbb46713 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "@rainbow-me/apollo/*": ["./src/apollo/*"], "@rainbow-me/assets": ["./src/assets"], "@rainbow-me/config": ["./src/config"], + "@rainbow-me/config/*": ["./src/config/*"], "@rainbow-me/context": ["./src/context"], "@rainbow-me/design-system": ["src/design-system"], "@rainbow-me/entities": ["src/entities"], @@ -44,6 +45,7 @@ "@rainbow-me/utils": ["src/utils"], "@rainbow-me/utils/*": ["src/utils/*"], "@rainbow-me/performance/utils": ["src/performance/utils"], + "ens-avatar": ["src/ens-avatar/src"], "logger": ["src/utils/logger"], "react-native-cool-modals": ["src/react-native-cool-modals"], "react-native-yet-another-bottom-sheet": [ diff --git a/types/content-hash.d.ts b/types/content-hash.d.ts new file mode 100644 index 00000000000..779cbe5ea92 --- /dev/null +++ b/types/content-hash.d.ts @@ -0,0 +1 @@ +declare module '@ensdomains/content-hash'; diff --git a/src/eth-contract-metadata.d.ts b/types/eth-contract-metadata.d.ts similarity index 100% rename from src/eth-contract-metadata.d.ts rename to types/eth-contract-metadata.d.ts diff --git a/src/eth-ens-namehash.d.ts b/types/eth-ens-namehash.d.ts similarity index 100% rename from src/eth-ens-namehash.d.ts rename to types/eth-ens-namehash.d.ts diff --git a/yarn.lock b/yarn.lock index 2daa93e7ce2..bf20e20e7ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1837,6 +1837,13 @@ version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" +"@bradgarropy/use-countdown@1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@bradgarropy/use-countdown/-/use-countdown-1.4.1.tgz#35e68aabf24cf5e59c1a776e82a5352f5a100b8f" + integrity sha512-oTJaMaUAQFZ4w7B6O96tOBc/jvOMC5KFxrwPM0Q2Vc4tLJYof0WK4Q9pCNV+CZlKjpSKRvq/FLdYvEZMgw7Jwg== + dependencies: + date-fns "^2.19.0" + "@capsizecss/core@3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@capsizecss/core/-/core-3.0.0.tgz#81b2fb222bd9716d211e4ddd0dc9cc42f71b4f37" @@ -1893,6 +1900,16 @@ ripemd160 "^2.0.2" sha3 "^2.1.3" +"@ensdomains/content-hash@2.5.7": + version "2.5.7" + resolved "https://registry.yarnpkg.com/@ensdomains/content-hash/-/content-hash-2.5.7.tgz#180e4ceb6e585a05d69ba307619d4a0cf12948f1" + integrity sha512-WNdGKCeubMIAfyPYTMlKeX6cgXKIEo42OcWPOLBiclzJwMibkVqpaGgWKVH9dniJq7bLXLa2tQ0k/F1pt6gUxA== + dependencies: + cids "^1.1.5" + js-base64 "^3.6.0" + multicodec "^3.2.0" + multihashes "^2.0.0" + "@ensdomains/ens@^0.4.4": version "0.4.5" resolved "https://registry.yarnpkg.com/@ensdomains/ens/-/ens-0.4.5.tgz#e0aebc005afdc066447c6e22feb4eda89a5edbfc" @@ -3550,6 +3567,11 @@ tweetnacl "^1.0.3" tweetnacl-util "^0.15.1" +"@multiformats/base-x@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@multiformats/base-x/-/base-x-4.0.1.tgz#95ff0fa58711789d53aefb2590a8b7a4e715d121" + integrity sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -4562,6 +4584,11 @@ version "1.5.5" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.5.tgz#75a2a8e7d8ab4b230414505d92335d1dcb53a6df" +"@types/qs@6.9.7": + version "6.9.7" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" + integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw== + "@types/react-native-dotenv@0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@types/react-native-dotenv/-/react-native-dotenv-0.2.0.tgz#32c58422a422c1adf68acce363ed791314d5a8e7" @@ -4659,6 +4686,11 @@ version "1.11.3" resolved "https://registry.yarnpkg.com/@types/underscore/-/underscore-1.11.3.tgz#d6734f3741ce41b2630018c6b61c6745f6188c07" +"@types/url-join@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/url-join/-/url-join-4.0.1.tgz#4989c97f969464647a8586c7252d97b449cdc045" + integrity sha512-wDXw9LEEUHyV+7UWy7U315nrJGJ7p1BzaCxDpEoLr789Dk1WDVMMlf3iBfbG2F8NdWnYyFbtTxUn2ZNbm1Q4LQ== + "@types/url-parse@1.4.3": version "1.4.3" resolved "https://registry.yarnpkg.com/@types/url-parse/-/url-parse-1.4.3.tgz#fba49d90f834951cb000a674efee3d6f20968329" @@ -4993,6 +5025,11 @@ version "1.1.0" resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" +"@zxing/text-encoding@0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b" + integrity sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA== + Base64@~0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/Base64/-/Base64-0.2.1.tgz#ba3a4230708e186705065e66babdd4c35cf60028" @@ -6334,15 +6371,15 @@ big-integer@1.6.36: resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.36.tgz#78631076265d4ae3555c04f85e7d9d2f3a071a36" integrity sha512-t70bfa7HYEA1D9idDbmuv7YbsbVkQ+Hp+8KFSul4aE5e/i1bjCNIRYJZlA8Q8p0r9T8cF/RVvwUgRA//FydEyg== -big-integer@1.6.x: - version "1.6.50" - resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.50.tgz#299a4be8bd441c73dcc492ed46b7169c34e92e70" - -big-integer@^1.6.16: +big-integer@1.6.51, big-integer@^1.6.16: version "1.6.51" resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686" integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg== +big-integer@1.6.x: + version "1.6.50" + resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.50.tgz#299a4be8bd441c73dcc492ed46b7169c34e92e70" + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -6939,6 +6976,16 @@ cids@^0.7.1: multicodec "^1.0.0" multihashes "~0.4.15" +cids@^1.1.5: + version "1.1.9" + resolved "https://registry.yarnpkg.com/cids/-/cids-1.1.9.tgz#402c26db5c07059377bcd6fb82f2a24e7f2f4a4f" + integrity sha512-l11hWRfugIcbGuTZwAM5PwpjPPjyb6UZOGwlHSnOBV5o07XhQ4gNpBN67FbODvpjyHtd+0Xs6KNvUcGBiDRsdg== + dependencies: + multibase "^4.0.1" + multicodec "^3.0.1" + multihashes "^4.0.1" + uint8arrays "^3.0.0" + cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" @@ -7675,6 +7722,11 @@ date-fns@2.16.1: version "2.16.1" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.16.1.tgz#05775792c3f3331da812af253e1a935851d3834b" +date-fns@^2.19.0: + version "2.28.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2" + integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw== + dayjs@^1.8.15: version "1.10.7" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.7.tgz#2cf5f91add28116748440866a0a1d26f3a6ce468" @@ -9427,6 +9479,11 @@ fast-safe-stringify@^2.0.6, fast-safe-stringify@^2.0.7: resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== +fast-text-encoding@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53" + integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig== + fast-xml-parser@^3.19.0: version "3.21.0" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-3.21.0.tgz#e5f18ba0d1f0f74ff0e08408e820ead56f61385b" @@ -11784,6 +11841,11 @@ js-base64@^2.1.9: version "2.6.4" resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.6.4.tgz#f4e686c5de1ea1f867dbcad3d46d969428df98c4" +js-base64@^3.6.0: + version "3.7.2" + resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.2.tgz#816d11d81a8aff241603d19ce5761e13e41d7745" + integrity sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ== + js-crc@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/js-crc/-/js-crc-0.2.0.tgz#f72c5c7618176bff75cc812a1cedbde3d8eb6839" @@ -13486,6 +13548,22 @@ multibase@^0.7.0: base-x "^3.0.8" buffer "^5.5.0" +multibase@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/multibase/-/multibase-2.0.0.tgz#e20a2a14813fa435dc69c702909209ac0741919e" + integrity sha512-xIrqUVsinSlFjqj+OtEgCJ6MRl5hXjHMBPWsUt1ZGSRMx8rzm+7hCLE4wDeSA3COomlUC9zHCoUlvWjvAMtfDg== + dependencies: + base-x "^3.0.8" + buffer "^5.5.0" + web-encoding "^1.0.2" + +multibase@^4.0.1: + version "4.0.6" + resolved "https://registry.yarnpkg.com/multibase/-/multibase-4.0.6.tgz#6e624341483d6123ca1ede956208cb821b440559" + integrity sha512-x23pDe5+svdLz/k5JPGCVdfn7Q5mZVMBETiC+ORfO+sor9Sgs0smJzAjfTbM5tckeCqnaUuMYoz+k3RXMmJClQ== + dependencies: + "@multiformats/base-x" "^4.0.1" + multibase@~0.6.0: version "0.6.1" resolved "https://registry.yarnpkg.com/multibase/-/multibase-0.6.1.tgz#b76df6298536cc17b9f6a6db53ec88f85f8cc12b" @@ -13506,6 +13584,24 @@ multicodec@^1.0.0: buffer "^5.6.0" varint "^5.0.0" +multicodec@^3.0.1, multicodec@^3.2.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/multicodec/-/multicodec-3.2.1.tgz#82de3254a0fb163a107c1aab324f2a91ef51efb2" + integrity sha512-+expTPftro8VAW8kfvcuNNNBgb9gPeNYV9dn+z1kJRWF2vih+/S79f2RVeIwmrJBUJ6NT9IUPWnZDQvegEh5pw== + dependencies: + uint8arrays "^3.0.0" + varint "^6.0.0" + +multiformats@9.6.2: + version "9.6.2" + resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-9.6.2.tgz#3dd8f696171a367fa826b7c432851da850eb115e" + integrity sha512-1dKng7RkBelbEZQQD2zvdzYKgUmtggpWl+GXQBYhnEGGkV6VIYfWgV3VSeyhcUFFEelI5q4D0etCJZ7fbuiamQ== + +multiformats@^9.4.2: + version "9.6.4" + resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-9.6.4.tgz#5dce1f11a407dbb69aa612cb7e5076069bb759ca" + integrity sha512-fCCB6XMrr6CqJiHNjfFNGT0v//dxOBMrOMqUIzpPc/mmITweLEyhvMpY9bF+jZ9z3vaMAau5E8B68DW77QMXkg== + multihashes@^0.4.15, multihashes@~0.4.15: version "0.4.21" resolved "https://registry.yarnpkg.com/multihashes/-/multihashes-0.4.21.tgz#dc02d525579f334a7909ade8a122dabb58ccfcb5" @@ -13514,6 +13610,25 @@ multihashes@^0.4.15, multihashes@~0.4.15: multibase "^0.7.0" varint "^5.0.0" +multihashes@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/multihashes/-/multihashes-2.0.0.tgz#4fa599d2d726ec6de33bf1e6f6d9f04b2351ace9" + integrity sha512-Mp94Y+7h3oWQx8JickVghlWR6VhRPDnlv/KZEUyNP0ISSkNEe3kQkWoyIGt1B45D6cTLoulg+MP6bugVewx32Q== + dependencies: + buffer "^5.6.0" + multibase "^2.0.0" + varint "^5.0.0" + web-encoding "^1.0.2" + +multihashes@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/multihashes/-/multihashes-4.0.3.tgz#426610539cd2551edbf533adeac4c06b3b90fb05" + integrity sha512-0AhMH7Iu95XjDLxIeuCOOE4t9+vQZsACyKZ9Fxw2pcsRmlX4iCn1mby0hS0bb+nQOVpdQYWPpnyusw4da5RPhA== + dependencies: + multibase "^4.0.1" + uint8arrays "^3.0.0" + varint "^5.0.2" + mute-stream@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" @@ -15270,9 +15385,10 @@ react-native-haptic-feedback@1.11.0: version "1.11.0" resolved "https://registry.yarnpkg.com/react-native-haptic-feedback/-/react-native-haptic-feedback-1.11.0.tgz#adfd841f3b67046532f912c6ec827aea0037d8ad" -react-native-image-crop-picker@0.32.3: - version "0.32.3" - resolved "https://registry.yarnpkg.com/react-native-image-crop-picker/-/react-native-image-crop-picker-0.32.3.tgz#53aa18249485e002e3a57a8cf70447482112b86e" +react-native-image-crop-picker@0.37.3: + version "0.37.3" + resolved "https://registry.yarnpkg.com/react-native-image-crop-picker/-/react-native-image-crop-picker-0.37.3.tgz#f260e40b6a6ba8e98f4db3dde25a8f09e0936385" + integrity sha512-ih+0pWWRUNEFQyaHwGbH9rqJNOb7EBYMwKJhTY0VmsKIA9E+usfwMmQXAFIfOnee7fTn0A2vOXkBCPQZwyvnQw== react-native-indicators@0.17.0: version "0.17.0" @@ -15280,9 +15396,10 @@ react-native-indicators@0.17.0: dependencies: prop-types "^15.5.10" -react-native-ios-context-menu@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/react-native-ios-context-menu/-/react-native-ios-context-menu-1.3.0.tgz#b877cbe9594afccfeb2f1659ee4aae829b986fc5" +react-native-ios-context-menu@1.7.4: + version "1.7.4" + resolved "https://registry.yarnpkg.com/react-native-ios-context-menu/-/react-native-ios-context-menu-1.7.4.tgz#37f3d32b292eef43f6126d9d708fafc471b1f442" + integrity sha512-qOu6VFVP2KH92HuXSzy6y86pKSfrkovJuWwXVnzc5PK6m40+H5+Ah8moUr5/yQJSD3FwlmqfKhyDMFO5MSlaBg== react-native-ios11-devicecheck@0.0.3: version "0.0.3" @@ -17806,6 +17923,13 @@ uglify-es@^3.1.9: commander "~2.13.0" source-map "~0.6.1" +uint8arrays@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/uint8arrays/-/uint8arrays-3.0.0.tgz#260869efb8422418b6f04e3fac73a3908175c63b" + integrity sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA== + dependencies: + multiformats "^9.4.2" + ultron@~1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c" @@ -17913,6 +18037,11 @@ urix@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" +url-join@4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.1.tgz#b642e21a2646808ffa178c4c5fda39844e12cde7" + integrity sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA== + url-parse-lax@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73" @@ -18035,7 +18164,7 @@ util@0.10.4, util@^0.10.3: dependencies: inherits "2.0.3" -util@^0.12.0: +util@^0.12.0, util@^0.12.3: version "0.12.4" resolved "https://registry.yarnpkg.com/util/-/util-0.12.4.tgz#66121a31420df8f01ca0c464be15dfa1d1850253" dependencies: @@ -18090,10 +18219,15 @@ validator@13.7.0, validator@^7.0.0: version "13.7.0" resolved "https://registry.yarnpkg.com/validator/-/validator-13.7.0.tgz#4f9658ba13ba8f3d82ee881d3516489ea85c0857" -varint@^5.0.0: +varint@^5.0.0, varint@^5.0.2: version "5.0.2" resolved "https://registry.yarnpkg.com/varint/-/varint-5.0.2.tgz#5b47f8a947eb668b848e034dcfa87d0ff8a7f7a4" +varint@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/varint/-/varint-6.0.0.tgz#9881eb0ce8feaea6512439d19ddf84bf551661d0" + integrity sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg== + vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -18163,6 +18297,15 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +web-encoding@^1.0.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/web-encoding/-/web-encoding-1.1.5.tgz#fc810cf7667364a6335c939913f5051d3e0c4864" + integrity sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA== + dependencies: + util "^0.12.3" + optionalDependencies: + "@zxing/text-encoding" "0.9.0" + web3-bzz@1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/web3-bzz/-/web3-bzz-1.6.1.tgz#8430eb3cbb69baaee4981d190b840748c37a9ec2"