From 171f4ac07ae2e124de01b49835600d8feebf2c24 Mon Sep 17 00:00:00 2001 From: Skylar Barrera Date: Thu, 2 Nov 2023 14:15:36 -0400 Subject: [PATCH 01/16] walletconnect: sanitize 712 signs (#5159) * util * tx signatures * display * fix parsing --- .../transaction/TransactionMessage.js | 15 ++++++-- src/model/wallet.ts | 8 ++++- src/utils/signingUtils.ts | 35 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 src/utils/signingUtils.ts diff --git a/src/components/transaction/TransactionMessage.js b/src/components/transaction/TransactionMessage.js index 3441b546d27..a2ec6d6f67d 100644 --- a/src/components/transaction/TransactionMessage.js +++ b/src/components/transaction/TransactionMessage.js @@ -7,6 +7,8 @@ import { Text } from '../text'; import styled from '@/styled-thing'; import { padding } from '@/styles'; import { deviceUtils } from '@/utils'; +import { sanitizeTypedData } from '@/utils/signingUtils'; +import { RainbowError, logger } from '@/logger'; const deviceWidth = deviceUtils.dimensions.width; const horizontalPadding = 24; @@ -34,9 +36,18 @@ const TransactionMessage = ({ maxHeight = 150, message, method }) => { maximumHeight = 200; minimumHeight = 200; try { - msg = JSON.parse(message); + const parsedMessage = JSON.parse(message); + const sanitizedMessage = sanitizeTypedData(parsedMessage); + msg = sanitizedMessage; // eslint-disable-next-line no-empty - } catch (e) {} + } catch (e) { + logger.error( + new RainbowError('TransactionMessage: Error parsing message ', { + messageType: typeof message, + }) + ); + } + msg = JSON.stringify(msg, null, 4); } diff --git a/src/model/wallet.ts b/src/model/wallet.ts index 477c54e4249..27cd6e6fe46 100644 --- a/src/model/wallet.ts +++ b/src/model/wallet.ts @@ -71,6 +71,7 @@ import { import { DebugContext } from '@/logger/debugContext'; import { IS_ANDROID } from '@/env'; import { setHardwareTXError } from '@/navigation/HardwareWalletTxNavigator'; +import { sanitizeTypedData } from '@/utils/signingUtils'; export type EthereumPrivateKey = string; type EthereumMnemonic = string; @@ -473,8 +474,13 @@ export const signTypedDataMessage = async ( } try { let parsedData = message; + + // we need to parse the data different for both possible types try { - parsedData = typeof message === 'string' && JSON.parse(message); + parsedData = + typeof message === 'string' + ? sanitizeTypedData(JSON.parse(message)) + : sanitizeTypedData(message); // eslint-disable-next-line no-empty } catch (e) {} diff --git a/src/utils/signingUtils.ts b/src/utils/signingUtils.ts new file mode 100644 index 00000000000..ab09bf216a6 --- /dev/null +++ b/src/utils/signingUtils.ts @@ -0,0 +1,35 @@ +// This function removes all the keys from the message that are not present in the types +// preventing a know phising attack where the signature process could allow malicious DApps +// to trick users into signing an EIP-712 object different from the one presented +// in the signature approval preview. Consequently, users were at risk of unknowingly +// transferring control of their ERC-20 tokens, NFTs, etc to adversaries by signing +// hidden Permit messages. + +// For more info read https://www.coinspect.com/wallet-EIP-712-injection-vulnerability/ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const sanitizeTypedData = (data: any) => { + if (data.types[data.primaryType].length > 0) { + // Extract all the valid permit types for the primary type + const permitPrimaryTypes: string[] = data.types[data.primaryType].map( + (type: { name: string; type: string }) => type.name + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sanitizedMessage: any = {}; + // Extract all the message keys that matches the valid permit types + Object.keys(data.message).forEach(key => { + if (permitPrimaryTypes.includes(key)) { + sanitizedMessage[key] = data.message[key]; + } + }); + + const sanitizedData = { + ...data, + message: sanitizedMessage, + }; + + return sanitizedData; + } + return data; +}; From faf3b34782c5eea8734851f8520d5a4435a63e5b Mon Sep 17 00:00:00 2001 From: Ibrahim Taveras Date: Thu, 2 Nov 2023 15:59:16 -0400 Subject: [PATCH 02/16] bump to v1.9.9 (#5173) --- CHANGELOG.md | 9 +++++++++ android/app/build.gradle | 4 ++-- ios/Rainbow.xcodeproj/project.pbxproj | 8 ++++---- package.json | 2 +- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d376e60aa6a..e7c5ab9b9fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/) ### Fixed +## [1.9.8] (https://github.com/rainbow-me/rainbow/releases/tag/v1.9.8) + +### Added + +- WC dapp warnings +- e2e updates +- Fee updates to NFT Mints +- Account Asset improvements + ## [1.9.7] (https://github.com/rainbow-me/rainbow/releases/tag/v1.9.7) ### Added diff --git a/android/app/build.gradle b/android/app/build.gradle index 098f20cbfc0..4ef81c587c8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -152,8 +152,8 @@ android { applicationId "me.rainbow" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 192 - versionName "1.9.8" + versionCode 193 + versionName "1.9.9" missingDimensionStrategy 'react-native-camera', 'general' renderscriptTargetApi 23 renderscriptSupportModeEnabled true diff --git a/ios/Rainbow.xcodeproj/project.pbxproj b/ios/Rainbow.xcodeproj/project.pbxproj index c8cf7251057..ac248fec360 100644 --- a/ios/Rainbow.xcodeproj/project.pbxproj +++ b/ios/Rainbow.xcodeproj/project.pbxproj @@ -1517,7 +1517,7 @@ "$(PROJECT_DIR)", ); LLVM_LTO = YES; - MARKETING_VERSION = 1.9.8; + MARKETING_VERSION = 1.9.9; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(OTHER_CFLAGS)"; OTHER_LDFLAGS = ( @@ -1580,7 +1580,7 @@ "$(PROJECT_DIR)", ); LLVM_LTO = YES; - MARKETING_VERSION = 1.9.8; + MARKETING_VERSION = 1.9.9; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(OTHER_CFLAGS)"; OTHER_LDFLAGS = ( @@ -1688,7 +1688,7 @@ "$(PROJECT_DIR)", ); LLVM_LTO = YES; - MARKETING_VERSION = 1.9.8; + MARKETING_VERSION = 1.9.9; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(OTHER_CFLAGS)"; OTHER_LDFLAGS = ( @@ -1797,7 +1797,7 @@ "$(PROJECT_DIR)", ); LLVM_LTO = YES; - MARKETING_VERSION = 1.9.8; + MARKETING_VERSION = 1.9.9; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(OTHER_CFLAGS)"; OTHER_LDFLAGS = ( diff --git a/package.json b/package.json index 8828f7a0383..e5970d9a503 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Rainbow", - "version": "1.9.8-1", + "version": "1.9.9-1", "private": true, "scripts": { "setup": "yarn install && yarn graphql-codegen:install && yarn ds:install && yarn allow-scripts && yarn postinstall && yarn graphql-codegen", From 42fda7dade5b193edf549ade4d5c1f8fcc5ee5fd Mon Sep 17 00:00:00 2001 From: Skylar Barrera Date: Tue, 7 Nov 2023 11:42:18 -0500 Subject: [PATCH 03/16] wc: use methodRegistry on all networks (#5179) --- src/screens/TransactionConfirmationScreen.js | 5 +++-- src/utils/methodRegistry.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/screens/TransactionConfirmationScreen.js b/src/screens/TransactionConfirmationScreen.js index 96346c18b86..9f1a789bebb 100644 --- a/src/screens/TransactionConfirmationScreen.js +++ b/src/screens/TransactionConfirmationScreen.js @@ -364,7 +364,8 @@ export default function TransactionConfirmationScreen() { setMethodName(lang.t('wallet.transaction.request')); }, 5000); const { name } = await methodRegistryLookupAndParse( - methodSignaturePrefix + methodSignaturePrefix, + getNetworkObj(currentNetwork).id ); if (name) { setMethodName(name); @@ -375,7 +376,7 @@ export default function TransactionConfirmationScreen() { clearTimeout(fallbackHandler); } }, - [setMethodName] + [setMethodName, currentNetwork] ); useEffect(() => { diff --git a/src/utils/methodRegistry.ts b/src/utils/methodRegistry.ts index 0d4b4fffb27..36074dbd26d 100644 --- a/src/utils/methodRegistry.ts +++ b/src/utils/methodRegistry.ts @@ -8,7 +8,8 @@ import { CONTRACT_FUNCTION } from '@/apollo/queries'; const METHOD_REGISTRY_ADDRESS = '0x44691B39d1a75dC4E0A0346CBB15E310e6ED1E86'; export const methodRegistryLookupAndParse = async ( - methodSignatureBytes: any + methodSignatureBytes: any, + chainId: number ) => { let signature = ''; @@ -16,7 +17,7 @@ export const methodRegistryLookupAndParse = async ( { query: CONTRACT_FUNCTION, variables: { - chainID: 1, + chainID: chainId, hex: methodSignatureBytes, }, }, From 3456871b32729be8a830a7cada2461b4bad0eae8 Mon Sep 17 00:00:00 2001 From: Skylar Barrera Date: Tue, 7 Nov 2023 12:26:36 -0500 Subject: [PATCH 04/16] WC: enabled eth_sign but only for connections (#5180) * Revert "wallet: rm legacy eth_sign" This reverts commit 2ed90aed5ff58c8d6c7e237b2e9088d9cc43eb0f. * throw error on eth_sign requests * show error sheet --- src/languages/en_US.json | 1 + src/parsers/requests.js | 6 ++++++ src/screens/TransactionConfirmationScreen.js | 3 +++ src/utils/signingMethods.ts | 3 ++- src/walletConnect/index.tsx | 18 ++++++++++++++++++ src/walletConnect/types.ts | 3 ++- 6 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/languages/en_US.json b/src/languages/en_US.json index 1f1341b3c23..1a28782b82b 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -2296,6 +2296,7 @@ "read_only_wallet_on_signing_method": "It looks like you're using a read-only wallet, which is not allowed for this request.", "namespaces_invalid": "There was an issue with the namespaces requested by the dapp. Please try again or contact Rainbow and/or dapp support teams.", "request_invalid": "The request contained invalid parameters. Please try again or contact Rainbow and/or dapp support teams.", + "eth_sign": "Rainbow does not support legacy eth_sign due to security concerns. \n Please contact Rainbow and/or dapp support teams if you have any questions.", "request_unsupported_network": "The network specified in this request is not supported by Rainbow.", "request_unsupported_methods": "The RPC method(s) specified in this request are not supported by Rainbow." }, diff --git a/src/parsers/requests.js b/src/parsers/requests.js index 9e7717e369e..e214aa03772 100644 --- a/src/parsers/requests.js +++ b/src/parsers/requests.js @@ -12,6 +12,7 @@ import { logger } from '@/logger'; import { ethereumUtils } from '@/utils'; import { isSignTypedData, + SIGN, PERSONAL_SIGN, SEND_TRANSACTION, SIGN_TRANSACTION, @@ -53,6 +54,11 @@ export const getRequestDisplayDetails = ( dappNetwork ); } + if (payload.method === SIGN) { + const message = payload?.params?.find(p => !isAddress(p)); + const result = getMessageDisplayDetails(message, timestampInMs); + return result; + } if (payload.method === PERSONAL_SIGN) { let message = payload?.params?.find(p => !isAddress(p)); try { diff --git a/src/screens/TransactionConfirmationScreen.js b/src/screens/TransactionConfirmationScreen.js index 9f1a789bebb..71b9ea6f794 100644 --- a/src/screens/TransactionConfirmationScreen.js +++ b/src/screens/TransactionConfirmationScreen.js @@ -104,6 +104,7 @@ import { isTransactionDisplayType, PERSONAL_SIGN, SEND_TRANSACTION, + SIGN, SIGN_TYPED_DATA, SIGN_TYPED_DATA_V4, } from '@/utils/signingMethods'; @@ -885,6 +886,8 @@ export default function TransactionConfirmationScreen() { provider ); switch (method) { + case SIGN: + break; case PERSONAL_SIGN: response = await signPersonalMessage(message, existingWallet); break; diff --git a/src/utils/signingMethods.ts b/src/utils/signingMethods.ts index 6b497752692..823d9d4a2aa 100644 --- a/src/utils/signingMethods.ts +++ b/src/utils/signingMethods.ts @@ -1,11 +1,12 @@ export const PERSONAL_SIGN = 'personal_sign'; export const SEND_TRANSACTION = 'eth_sendTransaction'; +export const SIGN = 'eth_sign'; export const SIGN_TRANSACTION = 'eth_signTransaction'; export const SIGN_TYPED_DATA = 'eth_signTypedData'; export const SIGN_TYPED_DATA_V4 = 'eth_signTypedData_v4'; const displayTypes = { - message: [PERSONAL_SIGN, SIGN_TYPED_DATA, SIGN_TYPED_DATA_V4], + message: [SIGN, PERSONAL_SIGN, SIGN_TYPED_DATA, SIGN_TYPED_DATA_V4], transaction: [SEND_TRANSACTION, SIGN_TRANSACTION], }; diff --git a/src/walletConnect/index.tsx b/src/walletConnect/index.tsx index 1d082711127..3211f3bc4af 100644 --- a/src/walletConnect/index.tsx +++ b/src/walletConnect/index.tsx @@ -231,6 +231,7 @@ export function getApprovedNamespaces( } const SUPPORTED_SIGNING_METHODS = [ + RPCMethod.Sign, RPCMethod.PersonalSign, RPCMethod.SignTypedData, RPCMethod.SignTypedDataV1, @@ -644,6 +645,23 @@ export async function onSessionRequest( logger.DebugContext.walletconnect ); + // we allow eth sign for connections but we dont want to support actual singing + if (method === RPCMethod.Sign) { + await client.respondSessionRequest({ + topic, + response: formatJsonRpcError( + id, + `Rainbow does not support legacy eth_sign` + ), + }); + showErrorSheet({ + title: lang.t(T.errors.generic_title), + body: lang.t(T.errors.eth_sign), + sheetHeight: 270, + onClose: maybeGoBackAndClearHasPendingRedirect, + }); + return; + } if (isSupportedMethod(method as RPCMethod)) { const isSigningMethod = isSupportedSigningMethod(method as RPCMethod); const { address, message } = parseRPCParams({ diff --git a/src/walletConnect/types.ts b/src/walletConnect/types.ts index 0e1883edbee..053a6f18766 100644 --- a/src/walletConnect/types.ts +++ b/src/walletConnect/types.ts @@ -1,4 +1,5 @@ export enum RPCMethod { + Sign = 'eth_sign', PersonalSign = 'personal_sign', SignTypedData = 'eth_signTypedData', SignTypedDataV1 = 'eth_signTypedData_v1', @@ -17,7 +18,7 @@ export enum RPCMethod { export type RPCPayload = | { - method: RPCMethod.PersonalSign; + method: RPCMethod.Sign | RPCMethod.PersonalSign; params: [string, string]; } | { From f82c5f6bfdf33fc665d79e91476873c549dbba0d Mon Sep 17 00:00:00 2001 From: Skylar Barrera Date: Tue, 7 Nov 2023 17:28:49 -0500 Subject: [PATCH 05/16] lang: fix english revert (#5182) --- src/migrations/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 9dea165b0f5..7eabf31ee25 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -92,7 +92,7 @@ export async function runMigrations(migrations: Migration[]) { const ranMigrations = []; for (const migration of migrations) { - const migratedAt = false; + const migratedAt = storage.get([migration.name]); const isDeferable = Boolean(migration.defer); if (!migratedAt) { From 1c9d1cf5c63451b0282e3af2c112f7414809a664 Mon Sep 17 00:00:00 2001 From: Skylar Barrera Date: Tue, 7 Nov 2023 19:21:47 -0500 Subject: [PATCH 06/16] reviews: bump watch wallet threshold (#5181) --- src/utils/reviewAlert.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/reviewAlert.ts b/src/utils/reviewAlert.ts index 5c7dc3e0cc4..d2317f4cf99 100644 --- a/src/utils/reviewAlert.ts +++ b/src/utils/reviewAlert.ts @@ -26,7 +26,7 @@ export const numberOfTimesBeforePrompt: { AddingContact: 1, EnsNameSearch: 1, EnsNameRegistration: 1, - WatchWallet: 1, + WatchWallet: 2, NftFloorPriceVisit: 3, }; From a3719b7bd40c5f83267a96b1b836e3e34decc543 Mon Sep 17 00:00:00 2001 From: Jin Date: Wed, 8 Nov 2023 11:06:50 -0500 Subject: [PATCH 07/16] Cleanup unused coinrow components (#5142) * Remove unused BalanceCoinRow * Remove unused function in SharedValuesContext for isCoinListEdited * Remove RecyclerActivityList * Remove unused TransactionCoinRow * Remove unused ContractInteractionCoinRow * Remove unused BalanceText * Remove unused TransactionStatusBadge * Remove unused ProfileMasthead * Remove unused transactionActions helpers * Remove unused transaction helper functions --- src/components/activity-list/ActivityList.js | 17 +- .../activity-list/RecyclerActivityList.js | 196 --------------- src/components/coin-row/BalanceCoinRow.js | 191 --------------- src/components/coin-row/BalanceText.js | 11 - .../coin-row/ContractInteractionCoinRow.js | 50 ---- src/components/coin-row/TransactionCoinRow.js | 227 ------------------ .../coin-row/TransactionStatusBadge.js | 132 ---------- src/components/coin-row/index.js | 3 - src/components/profile/ProfileMasthead.js | 115 --------- src/components/profile/index.js | 1 - src/helpers/SharedValuesContext.tsx | 6 +- src/helpers/transactionActions.ts | 27 --- src/helpers/transactions.ts | 39 --- src/screens/ProfileScreen.js | 14 +- 14 files changed, 3 insertions(+), 1026 deletions(-) delete mode 100644 src/components/activity-list/RecyclerActivityList.js delete mode 100644 src/components/coin-row/BalanceCoinRow.js delete mode 100644 src/components/coin-row/BalanceText.js delete mode 100644 src/components/coin-row/ContractInteractionCoinRow.js delete mode 100644 src/components/coin-row/TransactionCoinRow.js delete mode 100644 src/components/coin-row/TransactionStatusBadge.js delete mode 100644 src/components/profile/ProfileMasthead.js delete mode 100644 src/helpers/transactionActions.ts diff --git a/src/components/activity-list/ActivityList.js b/src/components/activity-list/ActivityList.js index 8a4b3f47dc7..6de7f11be3a 100644 --- a/src/components/activity-list/ActivityList.js +++ b/src/components/activity-list/ActivityList.js @@ -7,11 +7,9 @@ import ActivityIndicator from '../ActivityIndicator'; import Spinner from '../Spinner'; import { ButtonPressAnimation } from '../animations'; import { CoinRowHeight } from '../coin-row/CoinRow'; -import { TRANSACTION_COIN_ROW_VERTICAL_PADDING } from '../coin-row/TransactionCoinRow'; import Text from '../text/Text'; import ActivityListEmptyState from './ActivityListEmptyState'; import ActivityListHeader from './ActivityListHeader'; -import RecyclerActivityList from './RecyclerActivityList'; import styled from '@/styled-thing'; import { useTheme } from '@/theme'; import { useSectionListScrollToTopContext } from '@/navigation/SectionListScrollToTopContext'; @@ -24,6 +22,7 @@ const sx = StyleSheet.create({ }); const ActivityListHeaderHeight = 42; +const TRANSACTION_COIN_ROW_VERTICAL_PADDING = 7; const getItemLayout = sectionListGetItemLayout({ getItemHeight: () => @@ -86,16 +85,13 @@ function ListFooterComponent({ label, onPress }) { } const ActivityList = ({ - addCashAvailable, hasPendingTransaction, header, isEmpty, isLoading, nativeCurrency, - navigation, network, nextPage, - recyclerListView, remainingItemsLabel, requests, sections, @@ -127,17 +123,6 @@ const ActivityList = ({ if (network === networkTypes.mainnet || sections.length) { if (isEmpty && !isLoading) { return {header}; - } else if (recyclerListView) { - return ( - - ); } else { return ( { - const props1 = r1?.header?.props; - const props2 = r2?.header?.props; - if ( - r1.hash === '_header' && - (props1?.accountAddress !== props2?.accountAddress || - props1?.accountName !== props2?.accountName || - props1?.accountColor !== props2?.accountColor) - ) { - return true; - } - - const r1Key = r1?.hash ?? r1?.displayDetails?.timestampInMs ?? ''; - const r2Key = r2?.hash ?? r2?.displayDetails?.timestampInMs ?? ''; - - return ( - r1Key !== r2Key || - r1?.contact !== r2?.contact || - r1?.native?.symbol !== r2?.native?.symbol || - r1?.pending !== r2?.pending - ); -}; - -export default class RecyclerActivityList extends PureComponent { - constructor(props) { - super(props); - - this.state = { - dataProvider: new DataProvider(hasRowChanged, this.getStableId), - headersIndices: [], - swappedIndices: [], - }; - - this.layoutProvider = new LayoutProvider( - index => { - if (index === 0) { - return props.showcase - ? ViewTypes.SHOWCASE_HEADER - : ViewTypes.COMPONENT_HEADER; - } - - if (this.state.headersIndices.includes(index)) { - return ViewTypes.HEADER; - } - - if (this.state.headersIndices.includes(index + 1)) { - return ViewTypes.FOOTER; - } - - if (this.state.swappedIndices.includes(index)) { - return ViewTypes.SWAPPED_ROW; - } - - return ViewTypes.ROW; - }, - (type, dim) => { - // This values has been hardcoded for omitting imports' cycle - dim.width = deviceUtils.dimensions.width; - if (type === ViewTypes.ROW) { - dim.height = 70; - } else if (type === ViewTypes.SWAPPED_ROW) { - dim.height = 70; - } else if (type === ViewTypes.SHOWCASE_HEADER) { - dim.height = 400; - } else if (type === ViewTypes.FOOTER) { - dim.height = 19; - } else if (type === ViewTypes.HEADER) { - dim.height = 39; - } else { - // this handles the inital list height offset atm - dim.height = 20; - } - } - ); - this.layoutProvider.shouldRefreshWithAnchoring = false; - } - - static getDerivedStateFromProps(props, state) { - const headersIndices = []; - const swappedIndices = []; - let index = 1; - const items = props.sections.reduce( - (ctx, section) => { - section.data.forEach(asset => { - if (asset.status === TransactionStatusTypes.swapped) { - swappedIndices.push(index); - } - index++; - }); - index = index + 2; - headersIndices.push(ctx.length); - return ctx - .concat([ - { - hash: section.title, - title: section.title, - }, - ]) - .concat(section.data) - .concat([{ hash: `${section.title}_end` }]); // footer - }, - [{ hash: '_header', header: props.header }] - ); // header - if (items.length > 1) { - items.pop(); // remove last footer - } - return { - dataProvider: state.dataProvider.cloneWithRows(items), - headersIndices, - swappedIndices, - }; - } - - getStableId = index => { - const row = this.state?.dataProvider?._data?.[index]; - return buildTransactionUniqueIdentifier(row); - }; - - handleListRef = ref => { - this.rlv = ref; - }; - - rowRenderer = (type, data) => { - if (type === ViewTypes.COMPONENT_HEADER) { - const header = ( - - ); - return null; - } - if (type === ViewTypes.HEADER) return ; - if (type === ViewTypes.FOOTER) return ; - - if (!data) return null; - if (!data.hash) return ; - if (!data.symbol && data.dappName) - return ; - return ; - }; - - render() { - return ( - - - - ); - } -} diff --git a/src/components/coin-row/BalanceCoinRow.js b/src/components/coin-row/BalanceCoinRow.js deleted file mode 100644 index d565b7ea0bb..00000000000 --- a/src/components/coin-row/BalanceCoinRow.js +++ /dev/null @@ -1,191 +0,0 @@ -import React, { Fragment, useCallback } from 'react'; -import Animated, { useAnimatedStyle } from 'react-native-reanimated'; -import { View } from 'react-primitives'; -import useCoinListEditOptions from '../../hooks/useCoinListEditOptions'; -import { useTheme } from '../../theme/ThemeContext'; -import { ButtonPressAnimation } from '../animations'; -import { FlexItem, Row } from '../layout'; -import BalanceText from './BalanceText'; -import BottomRowText from './BottomRowText'; -import CoinCheckButton from './CoinCheckButton'; -import CoinName from './CoinName'; -import CoinRow from './CoinRow'; -import { useIsCoinListEditedSharedValue } from '@/helpers/SharedValuesContext'; -import { buildAssetUniqueIdentifier } from '@/helpers/assets'; -import { useNavigation } from '@/navigation'; -import Routes from '@/navigation/routesNames'; -import styled from '@/styled-thing'; - -const editTranslateOffset = 37; - -const formatPercentageString = percentString => - percentString ? percentString.split('-').join('- ') : '-'; - -const BalanceCoinRowCoinCheckButton = styled(CoinCheckButton).attrs({ - left: 9.5, -})({ - top: 9, -}); - -const PercentageText = styled(BottomRowText).attrs({ - align: 'right', -})({ - color: ({ isPositive, theme: { colors } }) => - isPositive ? colors.green : colors.alpha(colors.blueGreyDark, 0.5), -}); - -const BottomRowContainer = ios - ? Fragment - : styled(Row).attrs({ marginBottom: 10, marginTop: -10 })({}); - -const TopRowContainer = ios - ? Fragment - : styled(Row).attrs({ - align: 'flex-start', - justify: 'flex-start', - marginTop: 0, - })({}); - -const PriceContainer = ios - ? View - : styled(View)({ - marginBottom: 3, - marginTop: -3, - }); - -const BottomRow = ({ balance, native }) => { - const { colors } = useTheme(); - const percentChange = native?.change; - const percentageChangeDisplay = formatPercentageString(percentChange); - - const isPositive = percentChange && percentageChangeDisplay.charAt(0) !== '-'; - - return ( - - - - {balance?.display ?? ''} - - - - - {percentageChangeDisplay} - - - - ); -}; - -const TopRow = ({ name, native, nativeCurrencySymbol }) => { - const nativeDisplay = native?.balance?.display; - const { colors } = useTheme(); - - return ( - - - {name} - - - - {nativeDisplay || `${nativeCurrencySymbol}0.00`} - - - - ); -}; - -const arePropsEqual = (prev, next) => { - const itemIdentifier = buildAssetUniqueIdentifier(prev.item); - const nextItemIdentifier = buildAssetUniqueIdentifier(next.item); - const isSameItem = itemIdentifier === nextItemIdentifier; - return isSameItem; -}; - -const BalanceCoinRow = ({ - containerStyles = null, - isFirstCoinRow = false, - item, - ...props -}) => { - const { toggleSelectedCoin } = useCoinListEditOptions(); - const isCoinListEditedSharedValue = useIsCoinListEditedSharedValue(); - const { navigate } = useNavigation(); - - const handleEditModePress = useCallback(() => { - toggleSelectedCoin(item.uniqueId); - }, [item.uniqueId, toggleSelectedCoin]); - - const handlePress = useCallback(() => { - if (isCoinListEditedSharedValue.value) { - handleEditModePress(); - } else { - navigate(Routes.EXPANDED_ASSET_SHEET, { - asset: item, - type: 'token', - }); - } - }, [isCoinListEditedSharedValue, handleEditModePress, navigate, item]); - - const paddingStyle = useAnimatedStyle( - () => ({ - paddingLeft: - (isCoinListEditedSharedValue.value ? 1 : 0) * editTranslateOffset, - position: 'absolute', - width: '100%', - }), - [] - ); - - const marginStyle = useAnimatedStyle( - () => ({ - marginLeft: - -editTranslateOffset * - 1.5 * - (isCoinListEditedSharedValue.value ? 0 : 1), - position: 'absolute', - }), - [] - ); - - const { hiddenCoinsObj, pinnedCoinsObj } = useCoinListEditOptions(); - const isPinned = pinnedCoinsObj[item.uniqueId]; - const isHidden = hiddenCoinsObj[item.uniqueId]; - return ( - - - - - - - - - - - - - ); -}; - -export default React.memo(BalanceCoinRow, arePropsEqual); diff --git a/src/components/coin-row/BalanceText.js b/src/components/coin-row/BalanceText.js deleted file mode 100644 index 582aa792e7b..00000000000 --- a/src/components/coin-row/BalanceText.js +++ /dev/null @@ -1,11 +0,0 @@ -import { Text } from '../text'; -import styled from '@/styled-thing'; - -const BalanceText = styled(Text).attrs(({ color, theme: { colors } }) => ({ - align: 'right', - color: color || colors.dark, - size: 'lmedium', - weight: 'medium', -}))({}); - -export default BalanceText; diff --git a/src/components/coin-row/ContractInteractionCoinRow.js b/src/components/coin-row/ContractInteractionCoinRow.js deleted file mode 100644 index a799fb08172..00000000000 --- a/src/components/coin-row/ContractInteractionCoinRow.js +++ /dev/null @@ -1,50 +0,0 @@ -import lang from 'i18n-js'; -import React, { useCallback } from 'react'; -import { ButtonPressAnimation } from '../animations'; -import { CoinIconSize, RequestVendorLogoIcon } from '../coin-icon'; -import CoinName from './CoinName'; -import CoinRow from './CoinRow'; -import TransactionStatusBadge from './TransactionStatusBadge'; -import styled from '@/styled-thing'; -import { ethereumUtils, showActionSheetWithOptions } from '@/utils'; - -const BottomRow = ({ dappName }) => {dappName}; - -const ContractInteractionVenderLogoIcon = styled(RequestVendorLogoIcon).attrs({ - borderRadius: CoinIconSize, -})({}); - -export default function ContractInteractionCoinRow({ - item: { hash, ...item }, - ...props -}) { - const handlePressTransaction = useCallback(() => { - if (!hash) return; - showActionSheetWithOptions( - { - cancelButtonIndex: 1, - options: [ - lang.t('exchange.coin_row.view_on_etherscan'), - lang.t('button.cancel'), - ], - }, - buttonIndex => { - if (buttonIndex === 0) { - ethereumUtils.openTransactionInBlockExplorer(hash); - } - } - ); - }, [hash]); - - return ( - - - - ); -} diff --git a/src/components/coin-row/TransactionCoinRow.js b/src/components/coin-row/TransactionCoinRow.js deleted file mode 100644 index 6bc3eb0017c..00000000000 --- a/src/components/coin-row/TransactionCoinRow.js +++ /dev/null @@ -1,227 +0,0 @@ -import lang from 'i18n-js'; -import { compact, startCase } from 'lodash'; -import React, { useCallback } from 'react'; -import { getRandomColor } from '../../styles/colors'; -import { useTheme } from '../../theme/ThemeContext'; -import { ButtonPressAnimation } from '../animations'; -import { CoinIconSize } from '../coin-icon'; -import { FlexItem, Row, RowWithMargins } from '../layout'; -import BalanceText from './BalanceText'; -import BottomRowText from './BottomRowText'; -import CoinName from './CoinName'; -import CoinRow from './CoinRow'; -import TransactionStatusBadge from './TransactionStatusBadge'; -import { TransactionStatusTypes, TransactionTypes } from '@/entities'; -import { TransactionActions } from '@/helpers/transactionActions'; -import { - getHumanReadableDate, - hasAddableContact, -} from '@/helpers/transactions'; -import { isValidDomainFormat } from '@/helpers/validators'; -import { useAccountSettings } from '@/hooks'; -import { useNavigation } from '@/navigation'; -import Routes from '@/navigation/routesNames'; -import { useUserAsset } from '@/resources/assets/useUserAsset'; -import { - abbreviations, - ethereumUtils, - showActionSheetWithOptions, -} from '@/utils'; - -const containerStyles = { - paddingLeft: 19, -}; - -export const TRANSACTION_COIN_ROW_VERTICAL_PADDING = 7; - -const contentStyles = android - ? { - height: CoinIconSize + TRANSACTION_COIN_ROW_VERTICAL_PADDING * 2, - } - : {}; - -const BottomRow = ({ description, native, status, type }) => { - const { colors } = useTheme(); - const isFailed = status === TransactionStatusTypes.failed; - const isReceived = - status === TransactionStatusTypes.received || - status === TransactionStatusTypes.purchased; - const isSent = status === TransactionStatusTypes.sent; - - const isOutgoingSwap = status === TransactionStatusTypes.swapped; - const isIncomingSwap = - status === TransactionStatusTypes.received && - type === TransactionTypes.trade; - - let coinNameColor = colors.dark; - if (isOutgoingSwap) coinNameColor = colors.alpha(colors.blueGreyDark, 0.5); - - let balanceTextColor = colors.alpha(colors.blueGreyDark, 0.5); - if (isReceived) balanceTextColor = colors.green; - if (isSent) balanceTextColor = colors.dark; - if (isIncomingSwap) balanceTextColor = colors.swapPurple; - if (isOutgoingSwap) balanceTextColor = colors.dark; - - const nativeDisplay = native?.display; - const balanceText = nativeDisplay - ? compact([isFailed || isSent ? '-' : null, nativeDisplay]).join(' ') - : ''; - - return ( - - - {description} - - - {balanceText} - - - ); -}; - -const TopRow = ({ balance, pending, status, title }) => ( - - - - {balance?.display ?? ''} - - -); - -export default function TransactionCoinRow({ item, ...props }) { - const { contact } = item; - const { accountAddress } = useAccountSettings(); - const { navigate } = useNavigation(); - - const onPressTransaction = useCallback(async () => { - const { hash, from, minedAt, pending, to, status, type, network } = item; - - const date = getHumanReadableDate(minedAt); - const isSent = - status === TransactionStatusTypes.sending || - status === TransactionStatusTypes.sent; - const showContactInfo = hasAddableContact(status, type); - - const isOutgoing = from?.toLowerCase() === accountAddress.toLowerCase(); - const canBeResubmitted = isOutgoing && !minedAt; - const canBeCancelled = - canBeResubmitted && status !== TransactionStatusTypes.cancelling; - - const headerInfo = { - address: '', - divider: isSent - ? lang.t('exchange.coin_row.to_divider') - : lang.t('exchange.coin_row.from_divider'), - type: status.charAt(0).toUpperCase() + status.slice(1), - }; - - const contactAddress = isSent ? to : from; - let contactColor = 0; - - if (contact) { - headerInfo.address = contact.nickname; - contactColor = contact.color; - } else { - headerInfo.address = isValidDomainFormat(contactAddress) - ? contactAddress - : abbreviations.address(contactAddress, 4, 10); - contactColor = getRandomColor(); - } - - const blockExplorerAction = lang.t('exchange.coin_row.view_on', { - blockExplorerName: startCase(ethereumUtils.getBlockExplorer(network)), - }); - if (hash) { - const buttons = [ - ...(canBeResubmitted ? [TransactionActions.speedUp] : []), - ...(canBeCancelled ? [TransactionActions.cancel] : []), - blockExplorerAction, - ...(ios ? [TransactionActions.close] : []), - ]; - if (showContactInfo) { - buttons.unshift( - contact - ? TransactionActions.viewContact - : TransactionActions.addToContacts - ); - } - - showActionSheetWithOptions( - { - cancelButtonIndex: buttons.length - 1, - options: buttons, - title: pending - ? `${headerInfo.type}${ - showContactInfo - ? ' ' + headerInfo.divider + ' ' + headerInfo.address - : '' - }` - : showContactInfo - ? `${headerInfo.type} ${date} ${headerInfo.divider} ${headerInfo.address}` - : `${headerInfo.type} ${date}`, - }, - buttonIndex => { - const action = buttons[buttonIndex]; - switch (action) { - case TransactionActions.viewContact: - case TransactionActions.addToContacts: - navigate(Routes.MODAL_SCREEN, { - address: contactAddress, - asset: item, - color: contactColor, - contact, - type: 'contact_profile', - }); - break; - case TransactionActions.speedUp: - navigate(Routes.SPEED_UP_AND_CANCEL_SHEET, { - tx: item, - type: 'speed_up', - }); - break; - case TransactionActions.cancel: - navigate(Routes.SPEED_UP_AND_CANCEL_SHEET, { - tx: item, - type: 'cancel', - }); - break; - case TransactionActions.close: - return; - case blockExplorerAction: - ethereumUtils.openTransactionInBlockExplorer(hash, network); - break; - default: { - return; - } - } - } - ); - } - }, [accountAddress, contact, item, navigate]); - - const { data: accountAsset } = useUserAsset( - `${item.address}_${item.network}` - ); - const mainnetAddress = accountAsset?.mainnetAddress; - - return ( - - - - ); -} diff --git a/src/components/coin-row/TransactionStatusBadge.js b/src/components/coin-row/TransactionStatusBadge.js deleted file mode 100644 index dd3e9569647..00000000000 --- a/src/components/coin-row/TransactionStatusBadge.js +++ /dev/null @@ -1,132 +0,0 @@ -import React from 'react'; -import Spinner from '../Spinner'; -import { Icon } from '../icons'; -import { Row } from '../layout'; -import { Text } from '../text'; -import { TransactionStatusTypes } from '@/entities'; -import { position } from '@/styles'; -import { magicMemo } from '@/utils'; - -const StatusProps = { - [TransactionStatusTypes.approved]: { - marginRight: 4, - name: 'dot', - }, - [TransactionStatusTypes.cancelled]: { - marginRight: 4, - }, - [TransactionStatusTypes.cancelling]: { - marginRight: 4, - }, - [TransactionStatusTypes.deposited]: { - name: 'sunflower', - style: { fontSize: 11, left: -1.3, marginBottom: 1.5, marginRight: 1 }, - }, - [TransactionStatusTypes.depositing]: { - marginRight: 4, - }, - [TransactionStatusTypes.approving]: { - marginRight: 4, - }, - [TransactionStatusTypes.swapping]: { - marginRight: 4, - }, - [TransactionStatusTypes.speeding_up]: { - marginRight: 4, - }, - [TransactionStatusTypes.dropped]: { - marginRight: 4, - name: 'closeCircled', - style: position.maxSizeAsObject(12), - }, - [TransactionStatusTypes.failed]: { - marginRight: 4, - name: 'closeCircled', - style: position.maxSizeAsObject(12), - }, - [TransactionStatusTypes.purchased]: { - marginRight: 2, - name: 'arrow', - }, - [TransactionStatusTypes.purchasing]: { - marginRight: 4, - }, - [TransactionStatusTypes.received]: { - marginRight: 2, - name: 'arrow', - }, - [TransactionStatusTypes.self]: { - marginRight: 4, - name: 'dot', - }, - [TransactionStatusTypes.sending]: { - marginRight: 4, - }, - [TransactionStatusTypes.sent]: { - marginRight: 3, - name: 'sendSmall', - }, - [TransactionStatusTypes.swapped]: { - marginRight: 3, - name: 'swap', - small: true, - style: position.maxSizeAsObject(12), - }, - [TransactionStatusTypes.contract_interaction]: { - name: 'robot', - style: { fontSize: 11, left: -1.3, marginBottom: 1.5, marginRight: 1 }, - }, - [TransactionStatusTypes.swapping]: { - marginRight: 4, - }, - [TransactionStatusTypes.withdrawing]: { - marginRight: 4, - }, - [TransactionStatusTypes.withdrew]: { - name: 'sunflower', - style: { fontSize: 11, left: -1.3, marginBottom: 1.5, marginRight: 1 }, - }, -}; - -const TransactionStatusBadge = ({ pending, status, style, title }) => { - const { colors } = useTheme(); - const isSwapping = status === TransactionStatusTypes.swapping; - - let statusColor = colors.alpha(colors.blueGreyDark, 0.7); - if (pending) { - if (isSwapping) { - statusColor = colors.swapPurple; - } else { - statusColor = colors.appleBlue; - } - } else if (status === TransactionStatusTypes.swapped) { - statusColor = colors.swapPurple; - } - - return ( - - {pending && ( - - )} - {status && Object.keys(StatusProps).includes(status) && ( - - )} - - {title} - - - ); -}; - -export default magicMemo(TransactionStatusBadge, [ - 'pending', - 'status', - 'title', -]); diff --git a/src/components/coin-row/index.js b/src/components/coin-row/index.js index 2ee887fca10..86680ae6c2f 100644 --- a/src/components/coin-row/index.js +++ b/src/components/coin-row/index.js @@ -1,15 +1,12 @@ -export { default as BalanceCoinRow } from './BalanceCoinRow'; export { default as BottomRowText } from './BottomRowText'; export { default as CoinRow, CoinRowHeight } from './CoinRow'; export { default as CoinRowAddButton } from './CoinRowAddButton'; export { default as CoinRowDetailsIcon } from './CoinRowDetailsIcon'; export { default as CoinRowFavoriteButton } from './CoinRowFavoriteButton'; export { default as CollectiblesSendRow } from './CollectiblesSendRow'; -export { default as ContractInteractionCoinRow } from './ContractInteractionCoinRow'; export { default as ListCoinRow } from './ListCoinRow'; export { default as RequestCoinRow } from './RequestCoinRow'; export { default as SendCoinRow } from './SendCoinRow'; export { default as SendSavingsCoinRow } from './SendSavingsCoinRow'; export { default as FastTransactionCoinRow } from './FastTransactionCoinRow'; -export { default as TransactionCoinRow } from './TransactionCoinRow'; export { default as UnderlyingAssetCoinRow } from './UnderlyingAssetCoinRow'; diff --git a/src/components/profile/ProfileMasthead.js b/src/components/profile/ProfileMasthead.js deleted file mode 100644 index 402ba43b89d..00000000000 --- a/src/components/profile/ProfileMasthead.js +++ /dev/null @@ -1,115 +0,0 @@ -import React, { useCallback } from 'react'; -import Divider from '../Divider'; -import { ButtonPressAnimation } from '../animations'; -import { Icon } from '../icons'; -import { Centered, Column, Row } from '../layout'; -import { TruncatedText } from '../text'; -import AvatarCircle from './AvatarCircle'; -import { useAccountProfile, useDimensions, useOnAvatarPress } from '@/hooks'; -import { useNavigation } from '@/navigation'; -import Routes from '@/navigation/routesNames'; -import styled from '@/styled-thing'; -import { abbreviations } from '@/utils'; -import { useForegroundColor } from '@/design-system'; - -// NOTE: -// If you’re trying to edit this file for iOS and you’re not seeing any changes, -// that’s because iOS is using the Swift version — TransactionListViewHeader. -// Only Android is using this file at the moment. - -const dropdownArrowWidth = 21; - -const AccountName = styled(TruncatedText).attrs({ - align: 'left', - firstSectionLength: abbreviations.defaultNumCharsPerSection, - letterSpacing: 'roundedMedium', - size: 'big', - truncationLength: 4, - weight: 'bold', -})({ - height: android ? 38 : 33, - marginBottom: android ? 10 : 1, - marginTop: android ? -10 : -1, - maxWidth: ({ deviceWidth }) => deviceWidth - dropdownArrowWidth - 60, - paddingRight: 6, -}); - -const DropdownArrow = styled(Centered)({ - height: 5, - marginTop: 11, - width: dropdownArrowWidth, -}); - -const ProfileMastheadDivider = styled(Divider).attrs( - ({ theme: { colors } }) => ({ - color: colors.rowDividerLight, - }) -)({ - marginTop: 19, - bottom: 0, - position: 'absolute', -}); - -export default function ProfileMasthead({ - recyclerListRef, - showBottomDivider = true, -}) { - const { width: deviceWidth } = useDimensions(); - const { navigate } = useNavigation(); - const { - accountColor, - accountSymbol, - accountName, - accountImage, - } = useAccountProfile(); - - const { - onAvatarPress, - avatarActionSheetOptions, - onSelectionCallback, - } = useOnAvatarPress(); - - const iconColor = useForegroundColor('secondary60 (Deprecated)'); - - const handlePressAvatar = useCallback(() => { - recyclerListRef?.scrollToTop(true); - setTimeout( - onAvatarPress, - recyclerListRef?.getCurrentScrollOffset() > 0 ? 200 : 1 - ); - }, [onAvatarPress, recyclerListRef]); - - const handlePressChangeWallet = useCallback(() => { - navigate(Routes.CHANGE_WALLET_SHEET); - }, [navigate]); - - return ( - - {/* [AvatarCircle -> ImageAvatar -> ImgixImage], so no need to sign accountImage here. */} - - - - - {accountName} - - - - - - - {showBottomDivider && } - - ); -} diff --git a/src/components/profile/index.js b/src/components/profile/index.js index c492a1b68f3..0fea7d824f4 100644 --- a/src/components/profile/index.js +++ b/src/components/profile/index.js @@ -1,3 +1,2 @@ export { default as AvatarCircle } from './AvatarCircle'; export { default as ProfileAction } from './ProfileAction'; -export { default as ProfileMasthead } from './ProfileMasthead'; diff --git a/src/helpers/SharedValuesContext.tsx b/src/helpers/SharedValuesContext.tsx index 4c5bf6a6686..d7d2035c8db 100644 --- a/src/helpers/SharedValuesContext.tsx +++ b/src/helpers/SharedValuesContext.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useContext, useMemo } from 'react'; +import React, { PropsWithChildren, useMemo } from 'react'; import { useSharedValue, SharedValue } from 'react-native-reanimated'; import { useCoinListEdited } from '@/hooks'; @@ -22,7 +22,3 @@ export function SharedValuesProvider({ children }: PropsWithChildren) { ); return {children}; } - -export function useIsCoinListEditedSharedValue() { - return useContext(Context)!.isCoinListEdited; -} diff --git a/src/helpers/transactionActions.ts b/src/helpers/transactionActions.ts deleted file mode 100644 index 8c18249793b..00000000000 --- a/src/helpers/transactionActions.ts +++ /dev/null @@ -1,27 +0,0 @@ -import * as i18n from '@/languages'; - -export const TransactionActions = { - addToContacts: i18n.t(i18n.l.transactions.actions.addToContacts), - cancel: i18n.t(i18n.l.transactions.actions.cancel), - close: i18n.t(i18n.l.transactions.actions.close), - speedUp: i18n.t(i18n.l.transactions.actions.speedUp), - trySwapAgain: i18n.t(i18n.l.transactions.actions.trySwapAgain), - viewContact: i18n.t(i18n.l.transactions.actions.viewContact), -}; - -export function getShortTransactionActionId(name: string): string { - switch (name) { - case TransactionActions.addToContacts: - return 'add to contacts'; - case TransactionActions.cancel: - return 'cancel'; - case TransactionActions.close: - return 'close'; - case TransactionActions.speedUp: - return 'speed up'; - case TransactionActions.viewContact: - return 'view contact'; - default: - return 'block explorer'; - } -} diff --git a/src/helpers/transactions.ts b/src/helpers/transactions.ts index dfdbd5eac2a..b8057fd0314 100644 --- a/src/helpers/transactions.ts +++ b/src/helpers/transactions.ts @@ -1,16 +1,8 @@ -import { format } from 'date-fns'; import { TransactionStatus, - TransactionStatusTypes, TransactionType, TransactionTypes, } from '@/entities'; -import { getDateFnsLocale } from '@/languages'; - -export const buildTransactionUniqueIdentifier = ({ - hash, - displayDetails, -}: any) => hash || displayDetails?.timestampInMs; export const calculateTimestampOfToday = () => { const d = new Date(); @@ -45,37 +37,6 @@ export const yesterdayTimestamp = calculateTimestampOfYesterday(); export const thisMonthTimestamp = calculateTimestampOfThisMonth(); export const thisYearTimestamp = calculateTimestampOfThisYear(); -export function getHumanReadableDate(date: any) { - const timestamp = new Date(date * 1000); - - // i18n - return format( - timestamp, - // @ts-expect-error ts-migrate(2365) FIXME: Operator '>' cannot be applied to types 'Date' and... Remove this comment to see the full error message - timestamp > todayTimestamp - ? `'Today'` - : // @ts-expect-error ts-migrate(2365) FIXME: Operator '>' cannot be applied to types 'Date' and... Remove this comment to see the full error message - timestamp > yesterdayTimestamp - ? `'Yesterday'` - : // @ts-expect-error ts-migrate(2365) FIXME: Operator '>' cannot be applied to types 'Date' and... Remove this comment to see the full error message - `'on' MMM d${timestamp > thisYearTimestamp ? '' : ' yyyy'}`, - { locale: getDateFnsLocale() } - ); -} - -export function hasAddableContact(status: any, type: any) { - if ( - (status === TransactionStatusTypes.received && - type !== TransactionTypes.trade) || - status === TransactionStatusTypes.receiving || - status === TransactionStatusTypes.sending || - status === TransactionStatusTypes.sent - ) { - return true; - } - return false; -} - /** * Returns the `TransactionStatus` that represents completion for a given * transaction type. diff --git a/src/screens/ProfileScreen.js b/src/screens/ProfileScreen.js index 2c7a6bff5a7..082bca58905 100644 --- a/src/screens/ProfileScreen.js +++ b/src/screens/ProfileScreen.js @@ -1,17 +1,13 @@ import { useIsFocused } from '@react-navigation/native'; import React, { useCallback, useEffect, useState } from 'react'; -import { IS_TESTING } from 'react-native-dotenv'; import { ActivityList } from '../components/activity-list'; import { Page } from '../components/layout'; -import { ProfileMasthead } from '../components/profile'; -import NetworkTypes from '../helpers/networkTypes'; import { useNavigation } from '../navigation/Navigation'; import { ButtonPressAnimation } from '@/components/animations'; import { useAccountProfile, useAccountSettings, useAccountTransactions, - useContacts, useRequests, } from '@/hooks'; import Routes from '@/navigation/routesNames'; @@ -28,7 +24,7 @@ const ProfileScreenPage = styled(Page)({ flex: 1, }); -export default function ProfileScreen({ navigation }) { +export default function ProfileScreen() { const [activityListInitialized, setActivityListInitialized] = useState(false); const isFocused = useIsFocused(); const { navigate } = useNavigation(); @@ -43,7 +39,6 @@ export default function ProfileScreen({ navigation }) { sections, transactionsCount, } = accountTransactions; - const { contacts } = useContacts(); const { pendingRequestCount } = useRequests(); const { network } = useAccountSettings(); const { accountSymbol, accountColor, accountImage } = useAccountProfile(); @@ -60,10 +55,6 @@ export default function ProfileScreen({ navigation }) { navigate(Routes.CHANGE_WALLET_SHEET); }, [navigate]); - const addCashSupportedNetworks = network === NetworkTypes.mainnet; - const addCashAvailable = - IS_TESTING === 'true' ? false : addCashSupportedNetworks; - return ( Date: Wed, 8 Nov 2023 16:49:29 -0500 Subject: [PATCH 08/16] nonces: reset nonces (#5170) * resetNonces helper * name * reset nonces --- src/entities/nonce.ts | 2 +- src/redux/nonceManager.ts | 18 ++++++++++++++++++ .../SettingsSheet/components/DevSection.tsx | 5 ++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/entities/nonce.ts b/src/entities/nonce.ts index 1eaf28bf28f..8e48c0c5c03 100644 --- a/src/entities/nonce.ts +++ b/src/entities/nonce.ts @@ -5,7 +5,7 @@ interface NetworkNonceInfo { nonce: number; } -type AccountNonceInfo = Record; +type AccountNonceInfo = Partial>; export interface NonceManager { [key: EthereumAddress]: AccountNonceInfo; diff --git a/src/redux/nonceManager.ts b/src/redux/nonceManager.ts index 715acd83451..84930984710 100644 --- a/src/redux/nonceManager.ts +++ b/src/redux/nonceManager.ts @@ -88,7 +88,25 @@ export const updateNonce = ( saveNonceManager(updatedNonceManager); } }; +export const resetNonces = (accountAddress: EthereumAddress) => async ( + dispatch: AppDispatch, + getState: AppGetState +) => { + const { nonceManager: currentNonceData } = getState(); + + const currentAccountAddress = accountAddress.toLowerCase(); + const updatedNonceManager: NonceManager = { + ...currentNonceData, + [currentAccountAddress]: {}, + }; + + dispatch({ + payload: updatedNonceManager, + type: NONCE_MANAGER_UPDATE_NONCE, + }); + saveNonceManager(updatedNonceManager); +}; // -- Reducer ----------------------------------------- // const INITIAL_STATE: NonceManager = {}; diff --git a/src/screens/SettingsSheet/components/DevSection.tsx b/src/screens/SettingsSheet/components/DevSection.tsx index b83aca5df21..fbb1a559f71 100644 --- a/src/screens/SettingsSheet/components/DevSection.tsx +++ b/src/screens/SettingsSheet/components/DevSection.tsx @@ -57,6 +57,7 @@ import { isAuthenticated } from '@/utils/authentication'; import { DATA_UPDATE_PENDING_TRANSACTIONS_SUCCESS } from '@/redux/data'; import { saveLocalPendingTransactions } from '@/handlers/localstorage/accountLocal'; import { getFCMToken } from '@/notifications/tokens'; +import { resetNonces } from '@/redux/nonceManager'; const DevSection = () => { const { navigate } = useNavigation(); @@ -213,12 +214,14 @@ const DevSection = () => { const clearPendingTransactions = async () => { // clear local storage saveLocalPendingTransactions([], accountAddress, Network.mainnet); - // clear redux dispatch({ payload: [], type: DATA_UPDATE_PENDING_TRANSACTIONS_SUCCESS, }); + + // reset nonces + resetNonces(accountAddress); }; const clearLocalStorage = async () => { From 1decd95d9e211ef26b80a4d874c7366a46a668ad Mon Sep 17 00:00:00 2001 From: Ibrahim Taveras Date: Mon, 13 Nov 2023 11:16:55 -0500 Subject: [PATCH 09/16] bump to v1.9.10 (#5183) --- CHANGELOG.md | 7 +++++++ android/app/build.gradle | 4 ++-- ios/Rainbow.xcodeproj/project.pbxproj | 8 ++++---- package.json | 2 +- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7c5ab9b9fd..f94ee527e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/) ### Fixed +## [1.9.9] (https://github.com/rainbow-me/rainbow/releases/tag/v1.9.9) + +### Added + +- Bug fixes +- WC improvements + ## [1.9.8] (https://github.com/rainbow-me/rainbow/releases/tag/v1.9.8) ### Added diff --git a/android/app/build.gradle b/android/app/build.gradle index 4ef81c587c8..deaa7ce1206 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -152,8 +152,8 @@ android { applicationId "me.rainbow" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 193 - versionName "1.9.9" + versionCode 194 + versionName "1.9.10" missingDimensionStrategy 'react-native-camera', 'general' renderscriptTargetApi 23 renderscriptSupportModeEnabled true diff --git a/ios/Rainbow.xcodeproj/project.pbxproj b/ios/Rainbow.xcodeproj/project.pbxproj index ac248fec360..49d8c502eaa 100644 --- a/ios/Rainbow.xcodeproj/project.pbxproj +++ b/ios/Rainbow.xcodeproj/project.pbxproj @@ -1517,7 +1517,7 @@ "$(PROJECT_DIR)", ); LLVM_LTO = YES; - MARKETING_VERSION = 1.9.9; + MARKETING_VERSION = 1.9.10; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(OTHER_CFLAGS)"; OTHER_LDFLAGS = ( @@ -1580,7 +1580,7 @@ "$(PROJECT_DIR)", ); LLVM_LTO = YES; - MARKETING_VERSION = 1.9.9; + MARKETING_VERSION = 1.9.10; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(OTHER_CFLAGS)"; OTHER_LDFLAGS = ( @@ -1688,7 +1688,7 @@ "$(PROJECT_DIR)", ); LLVM_LTO = YES; - MARKETING_VERSION = 1.9.9; + MARKETING_VERSION = 1.9.10; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(OTHER_CFLAGS)"; OTHER_LDFLAGS = ( @@ -1797,7 +1797,7 @@ "$(PROJECT_DIR)", ); LLVM_LTO = YES; - MARKETING_VERSION = 1.9.9; + MARKETING_VERSION = 1.9.10; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(OTHER_CFLAGS)"; OTHER_LDFLAGS = ( diff --git a/package.json b/package.json index e5970d9a503..cb3af886210 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Rainbow", - "version": "1.9.9-1", + "version": "1.9.10-1", "private": true, "scripts": { "setup": "yarn install && yarn graphql-codegen:install && yarn ds:install && yarn allow-scripts && yarn postinstall && yarn graphql-codegen", From bdb81394842a9e09c82d1591a9d543cedd85a433 Mon Sep 17 00:00:00 2001 From: Skylar Barrera Date: Mon, 13 Nov 2023 17:47:11 -0500 Subject: [PATCH 10/16] audit: axios + reservior client bump (#5184) * audit: axios + reservior client bump * ts --- package.json | 5 ++-- src/screens/NFTSingleOfferSheet/index.tsx | 8 +++---- src/screens/mints/MintSheet.tsx | 6 ++--- yarn.lock | 28 +++++++++-------------- 4 files changed, 21 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index cb3af886210..470d4bfdf48 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "@react-navigation/material-top-tabs": "6.6.2", "@react-navigation/native": "6.1.6", "@react-navigation/stack": "6.3.16", - "@reservoir0x/reservoir-sdk": "1.5.4", + "@reservoir0x/reservoir-sdk": "1.8.4", "@segment/analytics-react-native": "2.15.0", "@segment/sovran-react-native": "1.0.4", "@sentry/react-native": "3.4.1", @@ -431,7 +431,8 @@ "**/socket.io-parser": "4.2.3", "**/get-func-name": "3.0.0", "**/@babel/traverse": "7.23.2", - "**/browserify-sign": "4.2.2" + "**/browserify-sign": "4.2.2", + "**/axios": "1.6.1" }, "react-native": { "@tanstack/query-async-storage-persister": "@tanstack/query-async-storage-persister/build/esm/index", diff --git a/src/screens/NFTSingleOfferSheet/index.tsx b/src/screens/NFTSingleOfferSheet/index.tsx index 210cf6e565b..5f3c1a2b7f6 100644 --- a/src/screens/NFTSingleOfferSheet/index.tsx +++ b/src/screens/NFTSingleOfferSheet/index.tsx @@ -346,8 +346,8 @@ export function NFTSingleOfferSheet() { } step.items?.forEach(item => { if ( - item.txHash && - !txsRef.current.includes(item.txHash) && + item.txHashes?.[0] && + !txsRef.current.includes(item.txHashes?.[0]) && item.status === 'incomplete' ) { let tx; @@ -355,7 +355,7 @@ export function NFTSingleOfferSheet() { tx = { to: item.data?.to, from: item.data?.from, - hash: item.txHash, + hash: item.txHashes[0], network: offer.network, amount: offer.netAmount.decimal, asset: { @@ -370,7 +370,7 @@ export function NFTSingleOfferSheet() { tx = { to: item.data?.to, from: item.data?.from, - hash: item.txHash, + hash: item.txHashes[0], network: offer.network, nft, type: TransactionType.authorize, diff --git a/src/screens/mints/MintSheet.tsx b/src/screens/mints/MintSheet.tsx index 0ae8dc3597b..85ad4e87f9a 100644 --- a/src/screens/mints/MintSheet.tsx +++ b/src/screens/mints/MintSheet.tsx @@ -479,14 +479,14 @@ const MintSheet = () => { } step.items?.forEach(item => { if ( - item.txHash && - txRef.current !== item.txHash && + item.txHashes?.[0] && + txRef.current !== item.txHashes?.[0] && item.status === 'incomplete' ) { const tx = { to: item.data?.to, from: item.data?.from, - hash: item.txHash, + hash: item.txHashes[0], network: currentNetwork, amount: mintPriceAmount, asset: { diff --git a/yarn.lock b/yarn.lock index 86d0ebc4850..d673ee79686 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3566,10 +3566,10 @@ color "^4.2.3" warn-once "^0.1.0" -"@reservoir0x/reservoir-sdk@1.5.4": - version "1.5.4" - resolved "https://registry.yarnpkg.com/@reservoir0x/reservoir-sdk/-/reservoir-sdk-1.5.4.tgz#5390e6d23bf9009c6bcebe68744fc1d514bc5443" - integrity sha512-1sStxOp6PcvDvYUx1mKzWP+/Bu5n0v6lEu1PbLtPylCkr8Yh4aWU+00ABA+jM27VdqW3I7R/oJZBTlqWuVjnAQ== +"@reservoir0x/reservoir-sdk@1.8.4": + version "1.8.4" + resolved "https://registry.yarnpkg.com/@reservoir0x/reservoir-sdk/-/reservoir-sdk-1.8.4.tgz#f5b0d4ea5924ffea7fd666fcb2ffdd9ac289c919" + integrity sha512-7gznkMz8HfpmS04fIs4Q4XgpvAVHTCdIpMxeF+jY4ewazUJRJqpli0VgUHCeEwSG7u33eCcA1xZ5PyZxYhVSog== dependencies: axios "^0.27.2" @@ -5903,20 +5903,14 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.12.0.tgz#ce1c9d143389679e253b314241ea9aa5cec980d3" integrity sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg== -axios@^0.26.1: - version "0.26.1" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.26.1.tgz#1ede41c51fcf51bbbd6fd43669caaa4f0495aaa9" - integrity sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA== - dependencies: - follow-redirects "^1.14.8" - -axios@^0.27.2: - version "0.27.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972" - integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ== +axios@1.6.1, axios@^0.26.1, axios@^0.27.2: + version "1.6.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.1.tgz#76550d644bf0a2d469a01f9244db6753208397d7" + integrity sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g== dependencies: - follow-redirects "^1.14.9" + follow-redirects "^1.15.0" form-data "^4.0.0" + proxy-from-env "^1.1.0" babel-core@7.0.0-bridge.0, babel-core@^7.0.0-bridge.0: version "7.0.0-bridge.0" @@ -9723,7 +9717,7 @@ flow-parser@^0.206.0: resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.206.0.tgz#f4f794f8026535278393308e01ea72f31000bfef" integrity sha512-HVzoK3r6Vsg+lKvlIZzaWNBVai+FXTX1wdYhz/wVlH13tb/gOdLXmlTqy6odmTBhT5UoWUbq0k8263Qhr9d88w== -follow-redirects@1.14.8, follow-redirects@^1.12.1, follow-redirects@^1.14.8, follow-redirects@^1.14.9: +follow-redirects@1.14.8, follow-redirects@^1.12.1, follow-redirects@^1.15.0: version "1.14.8" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc" integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA== From c53aaee9515b8df4ce13c2be39d61ccbf7959972 Mon Sep 17 00:00:00 2001 From: Jin Date: Tue, 14 Nov 2023 13:01:26 -0500 Subject: [PATCH 11/16] Fixes for activity list coin icons and migrating some components to network instead of assetType (#5143) * Replace assetType with network in getUrlForTrustIconFallback * Update components using FastCoinIcon to pass network instead of assetType * Update local cache state updates in FastFallbackCoinIconImage and remove react-coin-icon usage inFastFallbackCoinIconImage --- .../FastComponents/FastBalanceCoinRow.tsx | 2 +- .../FastComponents/FastCoinBadge.tsx | 22 +++--- .../FastComponents/FastCoinIcon.tsx | 69 ++++--------------- .../FastCurrencySelectionRow.tsx | 4 +- .../FastFallbackCoinIconImage.tsx | 37 +++++----- src/components/coin-icon/CoinIcon.tsx | 15 ++-- src/components/coin-icon/CoinIconFallback.js | 5 +- .../coin-row/FastTransactionCoinRow.tsx | 4 +- src/components/exchange/ExchangeTokenRow.tsx | 4 +- src/utils/getUrlForTrustIconFallback.ts | 23 ++++--- 10 files changed, 74 insertions(+), 111 deletions(-) diff --git a/src/components/asset-list/RecyclerAssetList2/FastComponents/FastBalanceCoinRow.tsx b/src/components/asset-list/RecyclerAssetList2/FastComponents/FastBalanceCoinRow.tsx index 7967ba91228..edaa91a98bf 100644 --- a/src/components/asset-list/RecyclerAssetList2/FastComponents/FastBalanceCoinRow.tsx +++ b/src/components/asset-list/RecyclerAssetList2/FastComponents/FastBalanceCoinRow.tsx @@ -140,7 +140,7 @@ const MemoizedBalanceCoinRow = React.memo( - ) : shouldRenderLocalCoinIconImage ? ( - - - ) : shouldRenderContract ? ( ) : ( )} - {assetType && } + {network && } ); }); @@ -177,10 +142,6 @@ const sx = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - reactCoinIconImage: { - height: '100%', - width: '100%', - }, withShadow: { elevation: 6, shadowOffset: { diff --git a/src/components/asset-list/RecyclerAssetList2/FastComponents/FastCurrencySelectionRow.tsx b/src/components/asset-list/RecyclerAssetList2/FastComponents/FastCurrencySelectionRow.tsx index 0f04e6c0efe..38170e4ace3 100644 --- a/src/components/asset-list/RecyclerAssetList2/FastComponents/FastCurrencySelectionRow.tsx +++ b/src/components/asset-list/RecyclerAssetList2/FastComponents/FastCurrencySelectionRow.tsx @@ -14,7 +14,7 @@ import FastCoinIcon from './FastCoinIcon'; import ContextMenuButton from '@/components/native-context-menu/contextMenu'; import { Text } from '@/design-system'; import { isNativeAsset } from '@/handlers/assets'; -import { Network } from '@/helpers'; +import { Network } from '@/networks/types'; import { useAccountAsset } from '@/hooks'; import { colors, fonts, fontWithWidth, getFontSize } from '@/styles'; import { deviceUtils, ethereumUtils } from '@/utils'; @@ -151,7 +151,7 @@ export default React.memo(function FastCurrencySelectionRow({ React.ReactNode; }) { const { colors } = theme; - const imageUrl = getUrlForTrustIconFallback(address, assetType)!; + const imageUrl = getUrlForTrustIconFallback(address, network)!; const key = `${symbol}-${imageUrl}`; - const shouldShowImage = imagesCache[key] !== ImageState.NOT_FOUND; - const isLoaded = imagesCache[key] === ImageState.LOADED; + const [cacheStatus, setCacheStatus] = useState(imagesCache[key]); - // we store data inside the object outside the component - // so we can share it between component instances - // but we still want the component to pick up new changes - const forceUpdate = useForceUpdate(); + const shouldShowImage = cacheStatus !== ImageState.NOT_FOUND; + const isLoaded = cacheStatus === ImageState.LOADED; const onLoad = useCallback(() => { - if (imagesCache[key] === ImageState.LOADED) { + if (isLoaded) { return; } - imagesCache[key] = ImageState.LOADED; - forceUpdate(); - }, [key, forceUpdate]); + setCacheStatus(ImageState.LOADED); + }, [key, isLoaded]); + const onError = useCallback( // @ts-expect-error passed to an untyped JS component err => { @@ -58,15 +54,14 @@ export const FastFallbackCoinIconImage = React.memo( ? ImageState.NOT_FOUND : ImageState.ERROR; - if (imagesCache[key] === newError) { + if (cacheStatus === newError) { return; - } else { - imagesCache[key] = newError; } - forceUpdate(); + imagesCache[key] = newError; + setCacheStatus(newError); }, - [key, forceUpdate] + [cacheStatus, key] ); return ( diff --git a/src/components/coin-icon/CoinIcon.tsx b/src/components/coin-icon/CoinIcon.tsx index de467b9d627..ce787640f06 100644 --- a/src/components/coin-icon/CoinIcon.tsx +++ b/src/components/coin-icon/CoinIcon.tsx @@ -1,16 +1,15 @@ -import { isNil } from 'lodash'; import React, { useMemo } from 'react'; import { View, ViewProps } from 'react-native'; import ContractInteraction from '../../assets/contractInteraction.png'; import { useTheme } from '../../theme/ThemeContext'; import ChainBadge from './ChainBadge'; import { CoinIconFallback } from './CoinIconFallback'; -import { AssetTypes } from '@/entities'; +import { Network } from '@/networks/types'; import { useColorForAsset } from '@/hooks'; import { ImgixImage } from '@/components/images'; import styled from '@/styled-thing'; import { - getTokenMetadata, + ethereumUtils, isETH, magicMemo, CoinIcon as ReactCoinIcon, @@ -59,7 +58,6 @@ const CoinIcon: React.FC = ({ }) => { const color = useColorForAsset({ address: mainnet_address || address, - type: mainnet_address ? AssetTypes.token : type, }); const { colors, isDarkMode } = useTheme(); const forceFallback = !isETH(mainnet_address || address); @@ -69,6 +67,12 @@ const CoinIcon: React.FC = ({ const theme = useTheme(); + const network = mainnet_address + ? Network.mainnet + : type + ? ethereumUtils.getNetworkFromType(type) + : Network.mainnet; + return ( {isNotContractInteraction ? ( @@ -85,8 +89,7 @@ const CoinIcon: React.FC = ({ } size={size} symbol={symbol} - type={mainnet_address ? AssetTypes.token : type} - assetType={mainnet_address ? AssetTypes.token : type} + network={network} theme={theme} /> ) : ( diff --git a/src/components/coin-icon/CoinIconFallback.js b/src/components/coin-icon/CoinIconFallback.js index be9fd6c9665..4bc8dea489c 100644 --- a/src/components/coin-icon/CoinIconFallback.js +++ b/src/components/coin-icon/CoinIconFallback.js @@ -31,8 +31,8 @@ const fallbackIconStyle = size => { export const CoinIconFallback = fallbackProps => { const { address, - assetType, height, + network, symbol, shadowColor, theme, @@ -41,7 +41,7 @@ export const CoinIconFallback = fallbackProps => { } = fallbackProps; const { colors } = theme; - const imageUrl = getUrlForTrustIconFallback(address, assetType); + const imageUrl = getUrlForTrustIconFallback(address, network); const key = `${symbol}-${imageUrl}`; @@ -50,7 +50,6 @@ export const CoinIconFallback = fallbackProps => { const fallbackIconColor = useColorForAsset({ address, - assetType, }); // we store data inside the object outside the component diff --git a/src/components/coin-row/FastTransactionCoinRow.tsx b/src/components/coin-row/FastTransactionCoinRow.tsx index 19511125b42..db7d0343e1f 100644 --- a/src/components/coin-row/FastTransactionCoinRow.tsx +++ b/src/components/coin-row/FastTransactionCoinRow.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { StyleSheet, View } from 'react-native'; import { ButtonPressAnimation } from '../animations'; import FastCoinIcon from '../asset-list/RecyclerAssetList2/FastComponents/FastCoinIcon'; @@ -154,7 +154,7 @@ export default React.memo(function TransactionCoinRow({ ) : ( Date: Tue, 14 Nov 2023 11:04:03 -0700 Subject: [PATCH 12/16] Update copy for an Error Occurred message for Support (#5187) * change email subject and add a learn more button on the alert dialog * update copy --- src/helpers/support.ts | 16 ++++++++++++++-- src/languages/en_US.json | 5 +++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/helpers/support.ts b/src/helpers/support.ts index 3fadd2166c0..fb2a3a758b8 100644 --- a/src/helpers/support.ts +++ b/src/helpers/support.ts @@ -4,6 +4,7 @@ import { debounce } from 'lodash'; import Mailer from 'react-native-mail'; import { Alert } from '../components/alerts'; import * as i18n from '@/languages'; +import { Linking } from 'react-native'; const SupportEmailAddress = 'support@rainbow.me'; @@ -33,23 +34,34 @@ const handleMailError = debounce( 250 ); +const openLearnMorePage = () => + Linking.openURL( + 'https://support.rainbow.me/en/articles/7975958-an-error-occurred' + ); + const messageSupport = () => Mailer.mail(supportEmailOptions, handleMailError); const supportEmailOptions = { recipients: [SupportEmailAddress], - subject: '🌈️ Rainbow Support', + subject: '🌈️ Rainbow Support: An Error Occurred', }; export default function showWalletErrorAlert() { Alert({ + cancelable: true, buttons: [ + { + onPress: openLearnMorePage, + text: i18n.t(i18n.l.support.wallet_alert.learn_more), + }, { onPress: messageSupport, - style: 'cancel', + isPreferred: true, text: i18n.t(i18n.l.support.wallet_alert.message_support), }, { text: i18n.t(i18n.l.support.wallet_alert.close), + style: 'destructive', }, ], message: i18n.t(i18n.l.support.wallet_alert.message), diff --git a/src/languages/en_US.json b/src/languages/en_US.json index 1a28782b82b..5446923ee39 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -2233,8 +2233,9 @@ "wallet_alert": { "message_support": "Message Support", "close": "Close", - "message": "For help, please reach out to support! \nWe'll get back to you soon!", - "title": "An error occured" + "learn_more": "Learn More", + "message": "If you've recently swapped phones, you may need to reimport your wallets. For all other help, please reach out to support! \n\nWe'll get back to you soon!", + "title": "An error occurred" } }, "walletconnect": { From 97c18070b68c25e1f64053877cdda01d68d6d227 Mon Sep 17 00:00:00 2001 From: Skylar Barrera Date: Tue, 14 Nov 2023 13:05:48 -0500 Subject: [PATCH 13/16] sends: fix prev sends logic (#5185) * send: check tx.from * oop * lowercase --- src/screens/SendConfirmationSheet.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/screens/SendConfirmationSheet.tsx b/src/screens/SendConfirmationSheet.tsx index 74502d23f5f..88c5de0da9a 100644 --- a/src/screens/SendConfirmationSheet.tsx +++ b/src/screens/SendConfirmationSheet.tsx @@ -277,7 +277,10 @@ export const SendConfirmationSheet = () => { let sends = 0; let sendsCurrentNetwork = 0; transactions.forEach(tx => { - if (tx.to?.toLowerCase() === toAddress?.toLowerCase()) { + if ( + tx.to?.toLowerCase() === toAddress?.toLowerCase() && + tx.from?.toLowerCase() === accountAddress?.toLowerCase() + ) { sends += 1; if (tx.network === network) { sendsCurrentNetwork += 1; @@ -291,7 +294,13 @@ export const SendConfirmationSheet = () => { } } } - }, [isSendingToUserAccount, network, toAddress, transactions]); + }, [ + accountAddress, + isSendingToUserAccount, + network, + toAddress, + transactions, + ]); const contact = useMemo(() => { return contacts?.[toAddress?.toLowerCase()]; From 593d9cb9dd3a6b82245f8392d2b2b25d368c5911 Mon Sep 17 00:00:00 2001 From: Skylar Barrera Date: Tue, 14 Nov 2023 13:06:44 -0500 Subject: [PATCH 14/16] mints: fix insufficient eth check (#5186) * fix insufficient eth check * oop --- src/screens/mints/MintSheet.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/screens/mints/MintSheet.tsx b/src/screens/mints/MintSheet.tsx index 85ad4e87f9a..42f238709c4 100644 --- a/src/screens/mints/MintSheet.tsx +++ b/src/screens/mints/MintSheet.tsx @@ -237,7 +237,7 @@ const MintSheet = () => { const isMintingAvailable = !(isReadOnlyWallet || isHardwareWallet) && !!mintCollection.publicMintInfo && - !gasError; + (!gasError || insufficientEth); const imageColor = usePersistentDominantColorFromImage(imageUrl) ?? colors.paleBlue; @@ -265,9 +265,14 @@ const MintSheet = () => { accountAddress ) )?.balance?.amount ?? 0; + + const totalMintPrice = multiply(price.amount, quantity); + if (greaterThanOrEqualTo(totalMintPrice, nativeBalance)) { + setInsufficientEth(true); + return; + } const txFee = getTotalGasPrice(); const txFeeWithBuffer = multiply(txFee, 1.2); - const totalMintPrice = multiply(price.amount, quantity); // gas price + mint price setInsufficientEth( greaterThanOrEqualTo( @@ -343,7 +348,6 @@ const MintSheet = () => { value: item.data?.value, }; const gas = await estimateGas(tx, provider); - let l1GasFeeOptimism = null; // add l1Fee for OP Chains if (getNetworkObj(currentNetwork).gas.OptimismTxFee) { @@ -724,7 +728,6 @@ const MintSheet = () => { plusAction={() => setQuantity(1)} minusAction={() => setQuantity(-1)} buttonColor={imageColor} - disabled={!isMintingAvailable} maxValue={Number(maxMintsPerWallet)} /> From b433c99795f914533b2955bd618209465ddd7757 Mon Sep 17 00:00:00 2001 From: Matthew Wall Date: Wed, 15 Nov 2023 08:10:35 -0700 Subject: [PATCH 15/16] Feature: Add remote promo sheet (#5140) * feat: initial work on generalizing promo sheet checks and remote promo sheet component * more work on remote promo sheets * update queries * Update arc.graphql to expose promoSheet and promoSheetCollection * update campaign checks and consume getPromoSheet query * write promoSheet and promoSheetCollection queries * add remote promo sheet route and name * add a couple mmkv STORAGE_IDS to control whether or not we show promos * add remote promo sheets feature flag * tweak remote promo sheet logic * fix signing remote images * more sanity checks and refetch interval * add RemotePromoSheetProvider and remove unnecessary campaignChecks * tweak checks and add Context/Provider for controlling remote promo sheets * re-enable firstLaunch and hasViewed checks * another sanity check * fix hasNonZeroAssetBalance * update check fns * add campaign storage to @storage model * update fns and remove some unused ones * update arc.graphql query to include priority * update provider and sheet to use @storage * add priority tag to collection query * update check for campaign to use @/storage and abstraction of check-fns to reduce boilerplate * adjust asset check fns * syncronize feature unlocks and campaign checks * add notifications promo and cleanup analytic events * add nft offers promo sheet and cleanup priority logic * fix conflicting nft offers asset type with contentful * replace PromoSheet analytics with v2 * revert graphql arc config change and cleanup local promo sheets * enable i18n in contentful and pass locale through * enable i18n clientside * update language * remove unused campaigns folder and uncomment check * remove unused campaigns folder * fix lint and func name * pass all locales through localized fields * change default colors to hex * add specific address for testing preview purposes * final touches * re-add hasShown check * add isPreviewing actionFn to bypass hasShown check * get color from theme if primary/secondary button has that prop * add network to asset check --- src/App.js | 31 +-- src/analytics/event.ts | 10 + src/campaigns/swapsPromoCampaign.ts | 130 ----------- src/components/PromoSheet.tsx | 12 +- src/components/cards/NFTOffersCard/index.tsx | 2 +- .../remote-promo-sheet/RemotePromoSheet.tsx | 214 ++++++++++++++++++ .../RemotePromoSheetProvider.tsx | 78 +++++++ .../check-fns/hasNftOffers.ts | 15 ++ .../check-fns/hasNonZeroAssetBalance.ts | 38 ++++ .../check-fns/hasNonZeroTotalBalance.ts | 16 ++ .../check-fns/hasSwapTxn.ts | 17 ++ .../remote-promo-sheet/check-fns/index.ts | 7 + .../check-fns/isAfterCampaignLaunch.ts | 5 + .../check-fns/isSelectedWalletReadOnly.ts | 13 ++ .../check-fns/isSpecificAddress.ts | 14 ++ .../remote-promo-sheet/checkForCampaign.ts | 152 +++++++++++++ .../localCampaignChecks.ts} | 16 +- .../notificationsPromoCampaign.ts | 2 +- src/config/experimental.ts | 2 + .../unlockableAppIconCheck.ts | 2 + src/graphql/queries/arc.graphql | 35 +++ src/handlers/walletReadyEvents.ts | 6 +- src/languages/en_US.json | 21 ++ src/model/mmkv.ts | 2 + src/navigation/Routes.android.tsx | 6 +- src/navigation/Routes.ios.tsx | 6 +- src/navigation/routesNames.ts | 1 + .../promoSheet/promoSheetCollectionQuery.ts | 96 ++++++++ src/resources/promoSheet/promoSheetQuery.ts | 79 +++++++ src/resources/reservoir/nftOffersQuery.ts | 129 +++++++---- src/screens/ChangeWalletSheet.tsx | 9 +- src/screens/NFTOffersSheet/index.tsx | 2 +- src/screens/NFTSingleOfferSheet/index.tsx | 2 +- src/screens/NotificationsPromoSheet/index.tsx | 2 +- .../components/SettingsSection.tsx | 1 - src/screens/SwapsPromoSheet.tsx | 84 ------- src/storage/index.ts | 4 +- src/storage/schema.ts | 11 + src/utils/index.ts | 1 + src/utils/resolveFirstRejectLast.ts | 48 ++++ 40 files changed, 1004 insertions(+), 317 deletions(-) delete mode 100644 src/campaigns/swapsPromoCampaign.ts create mode 100644 src/components/remote-promo-sheet/RemotePromoSheet.tsx create mode 100644 src/components/remote-promo-sheet/RemotePromoSheetProvider.tsx create mode 100644 src/components/remote-promo-sheet/check-fns/hasNftOffers.ts create mode 100644 src/components/remote-promo-sheet/check-fns/hasNonZeroAssetBalance.ts create mode 100644 src/components/remote-promo-sheet/check-fns/hasNonZeroTotalBalance.ts create mode 100644 src/components/remote-promo-sheet/check-fns/hasSwapTxn.ts create mode 100644 src/components/remote-promo-sheet/check-fns/index.ts create mode 100644 src/components/remote-promo-sheet/check-fns/isAfterCampaignLaunch.ts create mode 100644 src/components/remote-promo-sheet/check-fns/isSelectedWalletReadOnly.ts create mode 100644 src/components/remote-promo-sheet/check-fns/isSpecificAddress.ts create mode 100644 src/components/remote-promo-sheet/checkForCampaign.ts rename src/{campaigns/campaignChecks.ts => components/remote-promo-sheet/localCampaignChecks.ts} (78%) rename src/{campaigns => components/remote-promo-sheet}/notificationsPromoCampaign.ts (98%) create mode 100644 src/resources/promoSheet/promoSheetCollectionQuery.ts create mode 100644 src/resources/promoSheet/promoSheetQuery.ts delete mode 100644 src/screens/SwapsPromoSheet.tsx create mode 100644 src/utils/resolveFirstRejectLast.ts diff --git a/src/App.js b/src/App.js index 8c84df24dfa..a5c6f9e420d 100644 --- a/src/App.js +++ b/src/App.js @@ -32,10 +32,7 @@ import { Playground } from './design-system/playground/Playground'; import { TransactionType } from './entities'; import appEvents from './handlers/appEvents'; import handleDeeplink from './handlers/deeplinks'; -import { - runFeatureAndCampaignChecks, - runWalletBackupStatusChecks, -} from './handlers/walletReadyEvents'; +import { runWalletBackupStatusChecks } from './handlers/walletReadyEvents'; import { getCachedProviderForNetwork, isHardHat, @@ -85,6 +82,7 @@ import branch from 'react-native-branch'; import { initializeReservoirClient } from '@/resources/reservoir/client'; import { ReviewPromptAction } from '@/storage/schema'; import { handleReviewPromptAction } from '@/utils/reviewAlert'; +import { RemotePromoSheetProvider } from '@/components/remote-promo-sheet/RemotePromoSheetProvider'; if (__DEV__) { reactNativeDisableYellowBox && LogBox.ignoreAllLogs(); @@ -187,15 +185,6 @@ class OldApp extends Component { // Everything we need to do after the wallet is ready goes here logger.info('✅ Wallet ready!'); runWalletBackupStatusChecks(); - - InteractionManager.runAfterInteractions(() => { - setTimeout(() => { - if (IS_TESTING === 'true') { - return; - } - runFeatureAndCampaignChecks(); - }, 2000); - }); } } @@ -284,13 +273,15 @@ class OldApp extends Component { {this.state.initialRoute && ( - - - - + + + + + + )} diff --git a/src/analytics/event.ts b/src/analytics/event.ts index 5af645fe65a..809d161da0c 100644 --- a/src/analytics/event.ts +++ b/src/analytics/event.ts @@ -13,6 +13,8 @@ export const event = { appStateChange: 'State change', analyticsTrackingDisabled: 'analytics_tracking.disabled', analyticsTrackingEnabled: 'analytics_tracking.enabled', + promoSheetShown: 'promo_sheet.shown', + promoSheetDismissed: 'promo_sheet.dismissed', swapSubmitted: 'Submitted Swap', // notification promo sheet was shown notificationsPromoShown: 'notifications_promo.shown', @@ -120,6 +122,14 @@ export type EventProperties = { inputCurrencySymbol: string; outputCurrencySymbol: string; }; + [event.promoSheetShown]: { + campaign: string; + time_viewed: number; + }; + [event.promoSheetDismissed]: { + campaign: string; + time_viewed: number; + }; [event.notificationsPromoShown]: undefined; [event.notificationsPromoPermissionsBlocked]: undefined; [event.notificationsPromoPermissionsGranted]: undefined; diff --git a/src/campaigns/swapsPromoCampaign.ts b/src/campaigns/swapsPromoCampaign.ts deleted file mode 100644 index 0b33ba1b760..00000000000 --- a/src/campaigns/swapsPromoCampaign.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { MMKV } from 'react-native-mmkv'; -import { - Campaign, - CampaignCheckType, - CampaignKey, - GenericCampaignCheckResponse, -} from './campaignChecks'; -import { EthereumAddress, RainbowTransaction } from '@/entities'; -import { Network } from '@/helpers/networkTypes'; -import WalletTypes from '@/helpers/walletTypes'; -import { RainbowWallet } from '@/model/wallet'; -import { Navigation } from '@/navigation'; -import { ethereumUtils, logger } from '@/utils'; -import store from '@/redux/store'; -import Routes from '@/navigation/routesNames'; -import { STORAGE_IDS } from '@/model/mmkv'; -import { RainbowNetworks } from '@/networks'; - -// Rainbow Router -const RAINBOW_ROUTER_ADDRESS: EthereumAddress = - '0x00000000009726632680fb29d3f7a9734e3010e2'; - -const swapsLaunchDate = new Date('2022-07-26'); -const isAfterSwapsLaunch = (tx: RainbowTransaction): boolean => { - if (tx.minedAt) { - const txDate = new Date(tx.minedAt * 1000); - return txDate > swapsLaunchDate; - } - return false; -}; - -const isSwapTx = (tx: RainbowTransaction): boolean => - tx?.to?.toLowerCase() === RAINBOW_ROUTER_ADDRESS; - -const mmkv = new MMKV(); - -export const swapsCampaignAction = async () => { - logger.log('Campaign: Showing Swaps Promo'); - - mmkv.set(CampaignKey.swapsLaunch, true); - setTimeout(() => { - logger.log('triggering swaps promo action'); - - Navigation.handleAction(Routes.SWAPS_PROMO_SHEET, {}); - }, 1000); -}; - -export enum SwapsPromoCampaignExclusion { - noAssets = 'no_assets', - alreadySwapped = 'already_swapped', - wrongNetwork = 'wrong_network', -} - -export const swapsCampaignCheck = async (): Promise< - SwapsPromoCampaignExclusion | GenericCampaignCheckResponse -> => { - const hasShownCampaign = mmkv.getBoolean(CampaignKey.swapsLaunch); - const isFirstLaunch = mmkv.getBoolean(STORAGE_IDS.FIRST_APP_LAUNCH); - - const { - selected: currentWallet, - }: { - selected: RainbowWallet | undefined; - } = store.getState().wallets; - - /** - * stop if: - * there's no wallet - * the current wallet is read only - * the campaign has already been activated - * the user is launching Rainbow for the first time - */ - if ( - !currentWallet || - currentWallet.type === WalletTypes.readOnly || - isFirstLaunch || - hasShownCampaign - ) { - return GenericCampaignCheckResponse.nonstarter; - } - - const { - accountAddress, - network: currentNetwork, - }: { - accountAddress: EthereumAddress; - network: Network; - } = store.getState().settings; - - if (currentNetwork !== Network.mainnet) - return SwapsPromoCampaignExclusion.wrongNetwork; - // transactions are loaded from the current wallet - const { transactions } = store.getState().data; - - const networks: Network[] = RainbowNetworks.filter( - network => network.features.swaps - ).map(network => network.value); - - // check native asset balances on networks that support swaps - let hasBalance = false; - await networks.forEach(async (network: Network) => { - const nativeAsset = await ethereumUtils.getNativeAssetForNetwork( - network, - accountAddress - ); - const balance = Number(nativeAsset?.balance?.amount); - if (balance > 0) { - hasBalance = true; - } - }); - - // if the wallet has no native asset balances then stop - if (!hasBalance) return SwapsPromoCampaignExclusion.noAssets; - - const hasSwapped = !!transactions.filter(isAfterSwapsLaunch).find(isSwapTx); - - // if they have not swapped yet, trigger campaign action - if (!hasSwapped) { - SwapsPromoCampaign.action(); - return GenericCampaignCheckResponse.activated; - } - return SwapsPromoCampaignExclusion.alreadySwapped; -}; - -export const SwapsPromoCampaign: Campaign = { - action: async () => await swapsCampaignAction(), - campaignKey: CampaignKey.swapsLaunch, - check: async () => await swapsCampaignCheck(), - checkType: CampaignCheckType.deviceOrWallet, -}; diff --git a/src/components/PromoSheet.tsx b/src/components/PromoSheet.tsx index 42a404aa8a4..d980c6aba65 100644 --- a/src/components/PromoSheet.tsx +++ b/src/components/PromoSheet.tsx @@ -3,8 +3,8 @@ import { ImageSourcePropType, StatusBar, ImageBackground } from 'react-native'; import LinearGradient from 'react-native-linear-gradient'; import MaskedView from '@react-native-masked-view/masked-view'; import { SheetActionButton, SheetHandle, SlackSheet } from '@/components/sheet'; -import { CampaignKey } from '@/campaigns/campaignChecks'; -import { analytics } from '@/analytics'; +import { CampaignKey } from '@/components/remote-promo-sheet/localCampaignChecks'; +import { analyticsV2 } from '@/analytics'; import { AccentColorProvider, Box, @@ -38,7 +38,7 @@ type PromoSheetProps = { backgroundColor: string; accentColor: string; sheetHandleColor?: string; - campaignKey: CampaignKey; + campaignKey: CampaignKey | string; header: string; subHeader: string; primaryButtonProps: SheetActionButtonProps; @@ -74,7 +74,7 @@ export function PromoSheet({ () => () => { if (!activated) { const timeElapsed = (Date.now() - renderedAt) / 1000; - analytics.track('Dismissed Feature Promo', { + analyticsV2.track(analyticsV2.event.promoSheetDismissed, { campaign: campaignKey, time_viewed: timeElapsed, }); @@ -86,12 +86,12 @@ export function PromoSheet({ const primaryButtonOnPress = useCallback(() => { activate(); const timeElapsed = (Date.now() - renderedAt) / 1000; - analytics.track('Activated Feature Promo Action', { + analyticsV2.track(analyticsV2.event.promoSheetShown, { campaign: campaignKey, time_viewed: timeElapsed, }); primaryButtonProps.onPress(); - }, [activate, campaignKey, primaryButtonProps.onPress, renderedAt]); + }, [activate, campaignKey, primaryButtonProps, renderedAt]); // We are not using `isSmallPhone` from `useDimensions` here as we // want to explicitly set a min height. diff --git a/src/components/cards/NFTOffersCard/index.tsx b/src/components/cards/NFTOffersCard/index.tsx index 64fee7d0cec..22d1ed5aca9 100644 --- a/src/components/cards/NFTOffersCard/index.tsx +++ b/src/components/cards/NFTOffersCard/index.tsx @@ -253,7 +253,7 @@ export const NFTOffersCard = () => { setCanRefresh(false); queryClient.invalidateQueries( nftOffersQueryKey({ - address: accountAddress, + walletAddress: accountAddress, }) ); }} diff --git a/src/components/remote-promo-sheet/RemotePromoSheet.tsx b/src/components/remote-promo-sheet/RemotePromoSheet.tsx new file mode 100644 index 00000000000..bcb3b71efb7 --- /dev/null +++ b/src/components/remote-promo-sheet/RemotePromoSheet.tsx @@ -0,0 +1,214 @@ +import React, { useCallback, useEffect } from 'react'; +import { useRoute, RouteProp } from '@react-navigation/native'; +import { get } from 'lodash'; + +import { useNavigation } from '@/navigation/Navigation'; +import { PromoSheet } from '@/components/PromoSheet'; +import { useTheme } from '@/theme'; +import { CampaignCheckResult } from './checkForCampaign'; +import { usePromoSheetQuery } from '@/resources/promoSheet/promoSheetQuery'; +import { maybeSignUri } from '@/handlers/imgix'; +import { campaigns } from '@/storage'; +import { delay } from '@/utils/delay'; +import { Linking } from 'react-native'; +import Routes from '@/navigation/routesNames'; +import { Language } from '@/languages'; +import { useAccountSettings } from '@/hooks'; + +const DEFAULT_HEADER_HEIGHT = 285; +const DEFAULT_HEADER_WIDTH = 390; + +type RootStackParamList = { + RemotePromoSheet: CampaignCheckResult; +}; + +type Item = { + title: Record; + description: Record; + icon: string; + gradient?: string; +}; + +const enum ButtonType { + Internal = 'Internal', + External = 'External', +} + +const getKeyForLanguage = ( + key: string, + promoSheet: any, + language: Language +) => { + if (!promoSheet) { + return ''; + } + + const objectOrPrimitive = get(promoSheet, key); + if (typeof objectOrPrimitive === 'undefined') { + return ''; + } + + if (objectOrPrimitive[language]) { + return objectOrPrimitive[language]; + } + + return objectOrPrimitive[Language.EN_US] ?? ''; +}; + +export function RemotePromoSheet() { + const { colors } = useTheme(); + const { goBack, navigate } = useNavigation(); + const { params } = useRoute< + RouteProp + >(); + const { campaignId, campaignKey } = params; + const { language } = useAccountSettings(); + + useEffect(() => { + return () => { + campaigns.set(['isCurrentlyShown'], false); + }; + }, []); + + const { data, error } = usePromoSheetQuery( + { + id: campaignId, + }, + { + enabled: !!campaignId, + } + ); + + const getButtonForType = (type: ButtonType) => { + switch (type) { + default: + case ButtonType.Internal: + return () => internalNavigation(); + case ButtonType.External: + return () => externalNavigation(); + } + }; + + const externalNavigation = useCallback(() => { + Linking.openURL(data?.promoSheet?.primaryButtonProps.props.url); + }, []); + + const internalNavigation = useCallback(() => { + goBack(); + + delay(300).then(() => + navigate( + (Routes as any)[data?.promoSheet?.primaryButtonProps.props.route], + { + ...(data?.promoSheet?.primaryButtonProps.props.options || {}), + } + ) + ); + }, [goBack, navigate, data?.promoSheet]); + + if (!data?.promoSheet || error) { + return null; + } + + const { + accentColor: accentColorString, + backgroundColor: backgroundColorString, + sheetHandleColor: sheetHandleColorString, + backgroundImage, + headerImage, + headerImageAspectRatio, + items, + primaryButtonProps, + secondaryButtonProps, + } = data.promoSheet; + + const accentColor = + (colors as { [key: string]: any })[accentColorString as string] ?? + accentColorString; + + const backgroundColor = + (colors as { [key: string]: any })[backgroundColorString as string] ?? + backgroundColorString; + + const sheetHandleColor = + (colors as { [key: string]: any })[sheetHandleColorString as string] ?? + sheetHandleColorString; + + const backgroundSignedImageUrl = backgroundImage?.url + ? maybeSignUri(backgroundImage.url) + : undefined; + + const headerSignedImageUrl = headerImage?.url + ? maybeSignUri(headerImage.url) + : undefined; + + return ( + { + const title = getKeyForLanguage('title', item, language as Language); + const description = getKeyForLanguage( + 'description', + item, + language as Language + ); + + let gradient = undefined; + if (item.gradient) { + gradient = get(colors.gradients, item.gradient); + } + + return { + ...item, + title, + description, + gradient, + }; + })} + /> + ); +} diff --git a/src/components/remote-promo-sheet/RemotePromoSheetProvider.tsx b/src/components/remote-promo-sheet/RemotePromoSheetProvider.tsx new file mode 100644 index 00000000000..f18975e0e40 --- /dev/null +++ b/src/components/remote-promo-sheet/RemotePromoSheetProvider.tsx @@ -0,0 +1,78 @@ +import React, { + useEffect, + createContext, + PropsWithChildren, + useCallback, + useContext, +} from 'react'; +import { IS_TESTING } from 'react-native-dotenv'; +import { InteractionManager } from 'react-native'; +import { noop } from 'lodash'; + +import { REMOTE_PROMO_SHEETS, useExperimentalFlag } from '@/config'; +import { logger } from '@/logger'; +import { campaigns } from '@/storage'; +import { checkForCampaign } from '@/components/remote-promo-sheet/checkForCampaign'; +import { runFeatureUnlockChecks } from '@/handlers/walletReadyEvents'; +import { runLocalCampaignChecks } from './localCampaignChecks'; + +interface WalletReadyContext { + isWalletReady: boolean; + runChecks: () => void; +} + +export const RemotePromoSheetContext = createContext({ + isWalletReady: false, + runChecks: noop, +}); + +type WalletReadyProvider = PropsWithChildren & WalletReadyContext; + +export const RemotePromoSheetProvider = ({ + isWalletReady = false, + children, +}: WalletReadyProvider) => { + const remotePromoSheets = useExperimentalFlag(REMOTE_PROMO_SHEETS); + + const runChecks = useCallback(async () => { + if (!isWalletReady) return; + + InteractionManager.runAfterInteractions(async () => { + setTimeout(async () => { + if (IS_TESTING === 'true') return; + + // Stop checking for promo sheets if the exp. flag is toggled off + if (!remotePromoSheets) { + logger.info('Campaigns: remote promo sheets is disabled'); + return; + } + + const showedFeatureUnlock = await runFeatureUnlockChecks(); + if (showedFeatureUnlock) return; + + const showedLocalPromo = await runLocalCampaignChecks(); + if (showedLocalPromo) return; + + checkForCampaign(); + }, 2_000); + }); + }, [isWalletReady, remotePromoSheets]); + + useEffect(() => { + runChecks(); + + return () => { + campaigns.remove(['lastShownTimestamp']); + campaigns.set(['isCurrentlyShown'], false); + }; + }, [runChecks]); + + return ( + + {children} + + ); +}; + +export const useRemotePromoSheetContext = () => + useContext(RemotePromoSheetContext); diff --git a/src/components/remote-promo-sheet/check-fns/hasNftOffers.ts b/src/components/remote-promo-sheet/check-fns/hasNftOffers.ts new file mode 100644 index 00000000000..0d977b313ee --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/hasNftOffers.ts @@ -0,0 +1,15 @@ +import store from '@/redux/store'; +import { fetchNftOffers } from '@/resources/reservoir/nftOffersQuery'; + +export async function hasNftOffers(): Promise { + const { accountAddress } = store.getState().settings; + + try { + const data = await fetchNftOffers({ walletAddress: accountAddress }); + if (!data?.nftOffers) return false; + + return data?.nftOffers?.length > 1; + } catch (e) { + return false; + } +} diff --git a/src/components/remote-promo-sheet/check-fns/hasNonZeroAssetBalance.ts b/src/components/remote-promo-sheet/check-fns/hasNonZeroAssetBalance.ts new file mode 100644 index 00000000000..8c3d31f1698 --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/hasNonZeroAssetBalance.ts @@ -0,0 +1,38 @@ +import store from '@/redux/store'; +import { EthereumAddress } from '@/entities'; +import { ActionFn } from '../checkForCampaign'; +import { fetchUserAssets } from '@/resources/assets/UserAssetsQuery'; +import { Network } from '@/helpers'; + +type props = { + assetAddress: EthereumAddress; + network?: Network; +}; + +export const hasNonZeroAssetBalance: ActionFn = async ({ + assetAddress, + network, +}) => { + const { accountAddress, nativeCurrency } = store.getState().settings; + + const assets = await fetchUserAssets({ + address: accountAddress, + currency: nativeCurrency, + connectedToHardhat: false, + }); + if (!assets || Object.keys(assets).length === 0) return false; + + const desiredAsset = Object.values(assets).find(asset => { + if (!network) { + return asset.uniqueId.toLowerCase() === assetAddress.toLowerCase(); + } + + return ( + asset.uniqueId.toLowerCase() === assetAddress.toLowerCase() && + asset.network === network + ); + }); + if (!desiredAsset) return false; + + return Number(desiredAsset.balance?.amount) > 0; +}; diff --git a/src/components/remote-promo-sheet/check-fns/hasNonZeroTotalBalance.ts b/src/components/remote-promo-sheet/check-fns/hasNonZeroTotalBalance.ts new file mode 100644 index 00000000000..2d0976b21df --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/hasNonZeroTotalBalance.ts @@ -0,0 +1,16 @@ +import store from '@/redux/store'; +import { fetchUserAssets } from '@/resources/assets/UserAssetsQuery'; + +export const hasNonZeroTotalBalance = async (): Promise => { + const { accountAddress, nativeCurrency } = store.getState().settings; + + const assets = await fetchUserAssets({ + address: accountAddress, + currency: nativeCurrency, + connectedToHardhat: false, + }); + + if (!assets || Object.keys(assets).length === 0) return false; + + return Object.values(assets).some(asset => Number(asset.balance?.amount) > 0); +}; diff --git a/src/components/remote-promo-sheet/check-fns/hasSwapTxn.ts b/src/components/remote-promo-sheet/check-fns/hasSwapTxn.ts new file mode 100644 index 00000000000..be6dcdff593 --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/hasSwapTxn.ts @@ -0,0 +1,17 @@ +import type { EthereumAddress, RainbowTransaction } from '@/entities'; +import store from '@/redux/store'; + +// Rainbow Router +const RAINBOW_ROUTER_ADDRESS: EthereumAddress = + '0x00000000009726632680fb29d3f7a9734e3010e2'; + +const isSwapTx = (tx: RainbowTransaction): boolean => + tx.to?.toLowerCase() === RAINBOW_ROUTER_ADDRESS; + +export const hasSwapTxn = async (): Promise => { + const { transactions } = store.getState().data; + + if (!transactions.length) return false; + + return !!transactions.find(isSwapTx); +}; diff --git a/src/components/remote-promo-sheet/check-fns/index.ts b/src/components/remote-promo-sheet/check-fns/index.ts new file mode 100644 index 00000000000..5e3c68d2474 --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/index.ts @@ -0,0 +1,7 @@ +export * from './hasNftOffers'; +export * from './hasNonZeroAssetBalance'; +export * from './hasNonZeroTotalBalance'; +export * from './hasSwapTxn'; +export * from './isAfterCampaignLaunch'; +export * from './isSelectedWalletReadOnly'; +export * from './isSpecificAddress'; diff --git a/src/components/remote-promo-sheet/check-fns/isAfterCampaignLaunch.ts b/src/components/remote-promo-sheet/check-fns/isAfterCampaignLaunch.ts new file mode 100644 index 00000000000..0f858f7e715 --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/isAfterCampaignLaunch.ts @@ -0,0 +1,5 @@ +import { PromoSheet } from '@/graphql/__generated__/arc'; + +export const isAfterCampaignLaunch = ({ launchDate }: PromoSheet): boolean => { + return new Date() > new Date(launchDate); +}; diff --git a/src/components/remote-promo-sheet/check-fns/isSelectedWalletReadOnly.ts b/src/components/remote-promo-sheet/check-fns/isSelectedWalletReadOnly.ts new file mode 100644 index 00000000000..594399a6f41 --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/isSelectedWalletReadOnly.ts @@ -0,0 +1,13 @@ +import store from '@/redux/store'; +import WalletTypes from '@/helpers/walletTypes'; + +export const isSelectedWalletReadOnly = (): boolean => { + const { selected } = store.getState().wallets; + + // if no selected wallet, we will treat it as a read-only wallet + if (!selected || selected.type === WalletTypes.readOnly) { + return true; + } + + return false; +}; diff --git a/src/components/remote-promo-sheet/check-fns/isSpecificAddress.ts b/src/components/remote-promo-sheet/check-fns/isSpecificAddress.ts new file mode 100644 index 00000000000..6a4111cce2b --- /dev/null +++ b/src/components/remote-promo-sheet/check-fns/isSpecificAddress.ts @@ -0,0 +1,14 @@ +import store from '@/redux/store'; +import { EthereumAddress } from '@/entities'; +import { ActionFn } from '../checkForCampaign'; + +type props = { + addresses: EthereumAddress[]; +}; + +export const isSpecificAddress: ActionFn = async ({ addresses }) => { + const { accountAddress } = store.getState().settings; + return addresses + .map(address => address.toLowerCase()) + .includes(accountAddress.toLowerCase()); +}; diff --git a/src/components/remote-promo-sheet/checkForCampaign.ts b/src/components/remote-promo-sheet/checkForCampaign.ts new file mode 100644 index 00000000000..76eacbc1d20 --- /dev/null +++ b/src/components/remote-promo-sheet/checkForCampaign.ts @@ -0,0 +1,152 @@ +import { InteractionManager } from 'react-native'; +import { Navigation } from '@/navigation'; +import Routes from '@/navigation/routesNames'; +import { fetchPromoSheetCollection } from '@/resources/promoSheet/promoSheetCollectionQuery'; +import { logger } from '@/logger'; +import { PromoSheet, PromoSheetOrder } from '@/graphql/__generated__/arc'; +import { campaigns, device } from '@/storage'; + +import * as fns from './check-fns'; + +type ActionObj = { + fn: string; + outcome: boolean; + props: object; +}; + +export type ActionFn = (props: T) => boolean | Promise; + +export type CampaignCheckResult = { + campaignId: string; + campaignKey: string; +}; + +const TIMEOUT_BETWEEN_PROMOS = 5 * 60 * 1000; // 5 minutes in milliseconds + +const timeBetweenPromoSheets = () => { + const lastShownTimestamp = campaigns.get(['lastShownTimestamp']); + + if (!lastShownTimestamp) return TIMEOUT_BETWEEN_PROMOS; + + return Date.now() - lastShownTimestamp; +}; + +export const checkForCampaign = async () => { + logger.info('Campaigns: Running Checks'); + if (timeBetweenPromoSheets() < TIMEOUT_BETWEEN_PROMOS) { + logger.info('Campaigns: Time between promos has not exceeded timeout'); + return; + } + + let isCurrentlyShown = campaigns.get(['isCurrentlyShown']); + if (isCurrentlyShown) { + logger.info('Campaigns: Promo sheet is already shown'); + return; + } + + const isReturningUser = device.get(['isReturningUser']); + + if (!isReturningUser) { + logger.info('Campaigns: First launch, not showing promo sheet'); + return; + } + + const { promoSheetCollection } = await fetchPromoSheetCollection({ + order: [PromoSheetOrder.PriorityDesc], + }); + + for (const promo of promoSheetCollection?.items || []) { + if (!promo) continue; + logger.info(`Campaigns: Checking ${promo.sys.id}`); + const result = await shouldPromptCampaign(promo as PromoSheet); + + logger.info(`Campaigns: ${promo.sys.id} will show: ${result}`); + if (result) { + isCurrentlyShown = campaigns.get(['isCurrentlyShown']); + if (!isCurrentlyShown) { + return triggerCampaign(promo as PromoSheet); + } + } + } +}; + +export const triggerCampaign = async ({ + campaignKey, + sys: { id: campaignId }, +}: PromoSheet) => { + logger.info(`Campaigns: Showing ${campaignKey} Promo`); + + setTimeout(() => { + campaigns.set([campaignKey as string], true); + campaigns.set(['isCurrentlyShown'], true); + campaigns.set(['lastShownTimestamp'], Date.now()); + InteractionManager.runAfterInteractions(() => { + Navigation.handleAction(Routes.REMOTE_PROMO_SHEET, { + campaignId, + campaignKey, + }); + }); + }, 1000); +}; + +export const shouldPromptCampaign = async ( + campaign: PromoSheet +): Promise => { + const { + campaignKey, + sys: { id }, + actions, + } = campaign; + + // if we aren't given proper campaign data or actions to check against, exit early here + if (!campaignKey || !id) return false; + + // sanity check to prevent showing a campaign twice to a user or potentially showing a campaign to a fresh user + const hasShown = campaigns.get([campaignKey]); + + logger.info( + `Campaigns: Checking if we should prompt campaign ${campaignKey}` + ); + + const isPreviewing = actions.some( + (action: ActionObj) => action.fn === 'isPreviewing' + ); + + // If the campaign has been viewed already or it's the first app launch, exit early + if (hasShown && !isPreviewing) { + logger.info(`Campaigns: User has already been shown ${campaignKey}`); + return false; + } + + const actionsArray = actions || ([] as ActionObj[]); + let shouldPrompt = true; + + for (const actionObj of actionsArray) { + const { fn, outcome, props = {} } = actionObj; + const action = __INTERNAL_ACTION_CHECKS[fn]; + if (typeof action === 'undefined') { + continue; + } + + logger.info(`Campaigns: Checking action ${fn}`); + const result = await action({ ...props, ...campaign }); + logger.info( + `Campaigns: [${fn}] matches desired outcome: => ${result === outcome}` + ); + + if (result !== outcome) { + shouldPrompt = false; + break; + } + } + + // if all action checks pass, we will show the promo to the user + return shouldPrompt; +}; + +export const __INTERNAL_ACTION_CHECKS: { + [key: string]: ActionFn; +} = Object.keys(fns).reduce((acc, fnKey) => { + acc[fnKey] = fns[fnKey as keyof typeof fns]; + return acc; +}, {} as { [key: string]: ActionFn }); diff --git a/src/campaigns/campaignChecks.ts b/src/components/remote-promo-sheet/localCampaignChecks.ts similarity index 78% rename from src/campaigns/campaignChecks.ts rename to src/components/remote-promo-sheet/localCampaignChecks.ts index b062736d66d..e25a1884063 100644 --- a/src/campaigns/campaignChecks.ts +++ b/src/components/remote-promo-sheet/localCampaignChecks.ts @@ -1,13 +1,8 @@ -import { - SwapsPromoCampaign, - SwapsPromoCampaignExclusion, -} from './swapsPromoCampaign'; import { NotificationsPromoCampaign } from './notificationsPromoCampaign'; import { analytics } from '@/analytics'; import { logger } from '@/utils'; export enum CampaignKey { - swapsLaunch = 'swaps_launch', notificationsLaunch = 'notifications_launch', } @@ -22,9 +17,7 @@ export enum GenericCampaignCheckResponse { nonstarter = 'nonstarter', } -export type CampaignCheckResponse = - | GenericCampaignCheckResponse - | SwapsPromoCampaignExclusion; +export type CampaignCheckResponse = GenericCampaignCheckResponse; export interface Campaign { action(): Promise; // Function to call on activating the campaign @@ -34,12 +27,9 @@ export interface Campaign { } // the ordering of this list is IMPORTANT, this is the order that campaigns will be run -export const activeCampaigns: Campaign[] = [ - SwapsPromoCampaign, - NotificationsPromoCampaign, -]; +export const activeCampaigns: Campaign[] = [NotificationsPromoCampaign]; -export const runCampaignChecks = async (): Promise => { +export const runLocalCampaignChecks = async (): Promise => { logger.log('Campaigns: Running Checks'); for (const campaign of activeCampaigns) { const response = await campaign.check(); diff --git a/src/campaigns/notificationsPromoCampaign.ts b/src/components/remote-promo-sheet/notificationsPromoCampaign.ts similarity index 98% rename from src/campaigns/notificationsPromoCampaign.ts rename to src/components/remote-promo-sheet/notificationsPromoCampaign.ts index ad59f1ca8cd..ad46a99c731 100644 --- a/src/campaigns/notificationsPromoCampaign.ts +++ b/src/components/remote-promo-sheet/notificationsPromoCampaign.ts @@ -4,7 +4,7 @@ import { CampaignCheckType, CampaignKey, GenericCampaignCheckResponse, -} from './campaignChecks'; +} from './localCampaignChecks'; import { RainbowWallet } from '@/model/wallet'; import { Navigation } from '@/navigation'; import { logger } from '@/logger'; diff --git a/src/config/experimental.ts b/src/config/experimental.ts index 21492c7ca8d..150b59374a0 100644 --- a/src/config/experimental.ts +++ b/src/config/experimental.ts @@ -19,6 +19,7 @@ export const OP_REWARDS = '$OP Rewards'; export const DEFI_POSITIONS = 'Defi Positions'; export const NFT_OFFERS = 'NFT Offers'; export const MINTS = 'Mints'; +export const REMOTE_PROMO_SHEETS = 'RemotePromoSheets'; /** * A developer setting that pushes log lines to an array in-memory so that @@ -47,6 +48,7 @@ export const defaultConfig: Record = { [DEFI_POSITIONS]: { settings: true, value: true }, [NFT_OFFERS]: { settings: true, value: true }, [MINTS]: { settings: true, value: false }, + [REMOTE_PROMO_SHEETS]: { settings: true, value: false }, }; const storageKey = 'config'; diff --git a/src/featuresToUnlock/unlockableAppIconCheck.ts b/src/featuresToUnlock/unlockableAppIconCheck.ts index 8abe06bdac4..1fb62662724 100644 --- a/src/featuresToUnlock/unlockableAppIconCheck.ts +++ b/src/featuresToUnlock/unlockableAppIconCheck.ts @@ -6,6 +6,7 @@ import { Navigation } from '@/navigation'; import { logger } from '@/utils'; import Routes from '@/navigation/routesNames'; import { analytics } from '@/analytics'; +import { campaigns } from '@/storage'; const mmkv = new MMKV(); @@ -69,6 +70,7 @@ export const unlockableAppIconCheck = async ( }, 300); }, handleClose: () => { + campaigns.set(['isCurrentlyShown'], false); analytics.track('Dismissed App Icon Unlock', { campaign: key }); }, }); diff --git a/src/graphql/queries/arc.graphql b/src/graphql/queries/arc.graphql index c05e9439b19..9d58ff97137 100644 --- a/src/graphql/queries/arc.graphql +++ b/src/graphql/queries/arc.graphql @@ -202,3 +202,38 @@ query getMintableCollections($walletAddress: String!) { } } } + +query getPromoSheetCollection($order: [PromoSheetOrder]) { + promoSheetCollection(order: $order) { + items { + sys { + id + } + campaignKey + launchDate + actions + priority + } + } +} + +query getPromoSheet($id: String!) { + promoSheet(id: $id) { + accentColor + actions + backgroundColor + backgroundImage { + url + } + header + headerImage { + url + } + headerImageAspectRatio + items + primaryButtonProps + secondaryButtonProps + sheetHandleColor + subHeader + } +} diff --git a/src/handlers/walletReadyEvents.ts b/src/handlers/walletReadyEvents.ts index e7c8bd98c6d..134ed6c7eb3 100644 --- a/src/handlers/walletReadyEvents.ts +++ b/src/handlers/walletReadyEvents.ts @@ -1,7 +1,7 @@ import { IS_TESTING } from 'react-native-dotenv'; import { triggerOnSwipeLayout } from '../navigation/onNavigationStateChange'; import { getKeychainIntegrityState } from './localstorage/globalSettings'; -import { runCampaignChecks } from '@/campaigns/campaignChecks'; +import { runLocalCampaignChecks } from '@/components/remote-promo-sheet/localCampaignChecks'; import { EthereumAddress } from '@/entities'; import WalletBackupStepTypes from '@/helpers/walletBackupStepTypes'; import WalletTypes from '@/helpers/walletTypes'; @@ -124,9 +124,9 @@ export const runFeatureUnlockChecks = async (): Promise => { return false; }; -export const runFeatureAndCampaignChecks = async () => { +export const runFeatureAndLocalCampaignChecks = async () => { const showingFeatureUnlock: boolean = await runFeatureUnlockChecks(); if (!showingFeatureUnlock) { - await runCampaignChecks(); + await runLocalCampaignChecks(); } }; diff --git a/src/languages/en_US.json b/src/languages/en_US.json index 5446923ee39..53106dde7f8 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -1453,6 +1453,27 @@ "subheader": "INTRODUCING" } }, + "nft_offers_launch": { + "primary_button": { + "has_offers": "View Offers", + "not_has_offers": "Close" + }, + "secondary_button": "Maybe Later", + "info_row_1": { + "title": "All your offers in one place", + "description": "View your offers across all NFT marketplaces." + }, + "info_row_2": { + "title": "Accept from Rainbow", + "description": "Accept offers in a single tap and swap to the token of your choosing." + }, + "info_row_3": { + "title": "Smart sort modes", + "description": "Compare to floor prices, or view your highest or most recent offers." + }, + "header": "Offers", + "subheader": "INTRODUCING" + }, "review": { "alert": { "are_you_enjoying_rainbow": "Are you enjoying Rainbow? 🥰", diff --git a/src/model/mmkv.ts b/src/model/mmkv.ts index f405c2b7a42..557ebcff321 100644 --- a/src/model/mmkv.ts +++ b/src/model/mmkv.ts @@ -11,6 +11,8 @@ export const STORAGE_IDS = { NOTIFICATIONS: 'NOTIFICATIONS', RAINBOW_TOKEN_LIST: 'LEAN_RAINBOW_TOKEN_LIST', SHOWN_SWAP_RESET_WARNING: 'SHOWN_SWAP_RESET_WARNING', + PROMO_CURRENTLY_SHOWN: 'PROMO_CURRENTLY_SHOWN', + LAST_PROMO_SHEET_TIMESTAMP: 'LAST_PROMO_SHEET_TIMESTAMP', }; export const clearAllStorages = () => { diff --git a/src/navigation/Routes.android.tsx b/src/navigation/Routes.android.tsx index 87300caa358..3967add7d59 100644 --- a/src/navigation/Routes.android.tsx +++ b/src/navigation/Routes.android.tsx @@ -23,7 +23,6 @@ import { SendConfirmationSheet } from '../screens/SendConfirmationSheet'; import SendSheet from '../screens/SendSheet'; import ShowcaseSheet from '../screens/ShowcaseSheet'; import SpeedUpAndCancelSheet from '../screens/SpeedUpAndCancelSheet'; -import SwapsPromoSheet from '../screens/SwapsPromoSheet'; import NotificationsPromoSheet from '../screens/NotificationsPromoSheet'; import TransactionConfirmationScreen from '../screens/TransactionConfirmationScreen'; import WalletConnectApprovalSheet from '../screens/WalletConnectApprovalSheet'; @@ -82,6 +81,7 @@ import PoapSheet from '@/screens/mints/PoapSheet'; import { PositionSheet } from '@/screens/positions/PositionSheet'; import MintSheet from '@/screens/mints/MintSheet'; import { MintsSheet } from '@/screens/MintsSheet/MintsSheet'; +import { RemotePromoSheet } from '@/components/remote-promo-sheet/RemotePromoSheet'; const Stack = createStackNavigator(); const OuterStack = createStackNavigator(); @@ -307,8 +307,8 @@ function BSNavigator() { )} + createQueryKey('promoSheetCollection', { order }, { persisterVersion: 1 }); + +type PromoSheetCollectionQueryKey = ReturnType< + typeof promoSheetCollectionQueryKey +>; + +// /////////////////////////////////////////////// +// Query Function + +async function promoSheetCollectionQueryFunction({ + queryKey: [{ order }], +}: QueryFunctionArgs) { + const data = await arcDevClient.getPromoSheetCollection({ order }); + return data; +} + +export type PromoSheetCollectionResult = QueryFunctionResult< + typeof promoSheetCollectionQueryFunction +>; + +// /////////////////////////////////////////////// +// Query Prefetcher + +export async function prefetchPromoSheetCollection( + { order }: PromoSheetCollectionArgs, + config: QueryConfig< + PromoSheetCollectionResult, + Error, + PromoSheetCollectionQueryKey + > = {} +) { + return await queryClient.prefetchQuery( + promoSheetCollectionQueryKey({ order }), + promoSheetCollectionQueryFunction, + config + ); +} + +// /////////////////////////////////////////////// +// Query Fetcher + +export async function fetchPromoSheetCollection({ + order, +}: PromoSheetCollectionArgs) { + return await queryClient.fetchQuery( + promoSheetCollectionQueryKey({ order }), + promoSheetCollectionQueryFunction, + { staleTime: defaultStaleTime } + ); +} + +// /////////////////////////////////////////////// +// Query Hook + +export function usePromoSheetCollectionQuery( + { order }: PromoSheetCollectionArgs = {}, + { + enabled, + refetchInterval = 30_000, + }: { enabled?: boolean; refetchInterval?: number } = {} +) { + return useQuery( + promoSheetCollectionQueryKey({ order }), + promoSheetCollectionQueryFunction, + { + enabled, + staleTime: defaultStaleTime, + refetchInterval, + } + ); +} diff --git a/src/resources/promoSheet/promoSheetQuery.ts b/src/resources/promoSheet/promoSheetQuery.ts new file mode 100644 index 00000000000..5a2fb3afc75 --- /dev/null +++ b/src/resources/promoSheet/promoSheetQuery.ts @@ -0,0 +1,79 @@ +import { useQuery } from '@tanstack/react-query'; + +import { + createQueryKey, + queryClient, + QueryConfig, + QueryFunctionArgs, + QueryFunctionResult, +} from '@/react-query'; + +import { arcDevClient } from '@/graphql'; + +// Set a default stale time of 10 seconds so we don't over-fetch +// (query will serve cached data & invalidate after 10s). +const defaultStaleTime = 60_000; + +export type PromoSheetArgs = { + id: string; +}; + +// /////////////////////////////////////////////// +// Query Key + +const promoSheetQueryKey = ({ id }: PromoSheetArgs) => + createQueryKey('promoSheet', { id }, { persisterVersion: 1 }); + +type PromoSheetQueryKey = ReturnType; + +// /////////////////////////////////////////////// +// Query Function + +async function promoSheetQueryFunction({ + queryKey: [{ id }], +}: QueryFunctionArgs) { + const data = await arcDevClient.getPromoSheet({ id }); + return data; +} + +export type PromoSheetResult = QueryFunctionResult< + typeof promoSheetQueryFunction +>; + +// /////////////////////////////////////////////// +// Query Prefetcher + +export async function prefetchPromoSheet( + { id }: PromoSheetArgs, + config: QueryConfig = {} +) { + return await queryClient.prefetchQuery( + promoSheetQueryKey({ id }), + promoSheetQueryFunction, + config + ); +} + +// /////////////////////////////////////////////// +// Query Fetcher + +export async function fetchPromoSheet({ id }: PromoSheetArgs) { + return await queryClient.fetchQuery( + promoSheetQueryKey({ id }), + promoSheetQueryFunction, + { staleTime: defaultStaleTime } + ); +} + +// /////////////////////////////////////////////// +// Query Hook + +export function usePromoSheetQuery( + { id }: PromoSheetArgs, + { enabled }: { enabled?: boolean } = {} +) { + return useQuery(promoSheetQueryKey({ id }), promoSheetQueryFunction, { + enabled, + staleTime: defaultStaleTime, + }); +} diff --git a/src/resources/reservoir/nftOffersQuery.ts b/src/resources/reservoir/nftOffersQuery.ts index 341d475dc81..e1b9a241f3f 100644 --- a/src/resources/reservoir/nftOffersQuery.ts +++ b/src/resources/reservoir/nftOffersQuery.ts @@ -8,15 +8,94 @@ import { NftOffer, SortCriterion, } from '@/graphql/__generated__/arc'; -import { createQueryKey, queryClient } from '@/react-query'; +import { + QueryFunctionArgs, + QueryFunctionResult, + createQueryKey, + queryClient, +} from '@/react-query'; import { useQuery } from '@tanstack/react-query'; -import { useEffect, useMemo } from 'react'; +import { useEffect } from 'react'; import { useRecoilValue } from 'recoil'; const graphqlClient = IS_PROD ? arcClient : arcDevClient; -export const nftOffersQueryKey = ({ address }: { address: string }) => - createQueryKey('nftOffers', { address }, { persisterVersion: 1 }); +export type NFTOffersArgs = { + walletAddress: string; + sortBy?: SortCriterion; +}; + +function sortNftOffers(nftOffers: NftOffer[], sortCriterion: SortCriterion) { + let sortedOffers; + switch (sortCriterion) { + case SortCriterion.TopBidValue: + sortedOffers = nftOffers + .slice() + .sort((a, b) => b.netAmount.usd - a.netAmount.usd); + break; + case SortCriterion.FloorDifferencePercentage: + sortedOffers = nftOffers + .slice() + .sort( + (a, b) => b.floorDifferencePercentage - a.floorDifferencePercentage + ); + break; + case SortCriterion.DateCreated: + sortedOffers = nftOffers + .slice() + .sort((a, b) => b.createdAt - a.createdAt); + break; + default: + sortedOffers = nftOffers; + } + return sortedOffers; +} + +export const nftOffersQueryKey = ({ + walletAddress, + sortBy = SortCriterion.TopBidValue, +}: NFTOffersArgs) => + createQueryKey( + 'nftOffers', + { walletAddress, sortBy }, + { persisterVersion: 1 } + ); + +type NFTOffersQueryKey = ReturnType; + +async function nftOffersQueryFunction({ + queryKey: [{ walletAddress, sortBy }], +}: QueryFunctionArgs) { + const data = await graphqlClient.getNFTOffers({ + walletAddress, + sortBy, + }); + return data; +} + +export type NftOffersResult = QueryFunctionResult< + typeof nftOffersQueryFunction +>; + +export async function fetchNftOffers({ + walletAddress, + sortBy = SortCriterion.TopBidValue, +}: NFTOffersArgs) { + const data = await graphqlClient.getNFTOffers({ + walletAddress, + // TODO: remove sortBy once the backend supports it + sortBy: SortCriterion.TopBidValue, + }); + + console.log(data); + + if (!data?.nftOffers) { + return null; + } + + const sortedOffers = sortNftOffers(data.nftOffers, sortBy); + return { ...data, nftOffers: sortedOffers }; +} /** * React Query hook that returns the the most profitable `NftOffer` for each NFT owned by the given wallet address. @@ -28,7 +107,7 @@ export function useNFTOffers({ walletAddress }: { walletAddress: string }) { const nftOffersEnabled = useExperimentalFlag(NFT_OFFERS); const sortCriterion = useRecoilValue(nftOffersSortAtom); const queryKey = nftOffersQueryKey({ - address: walletAddress, + walletAddress, }); const query = useQuery( @@ -47,45 +126,11 @@ export function useNFTOffers({ walletAddress }: { walletAddress: string }) { } ); - const sortedByValue = useMemo( - () => - query.data?.nftOffers - ?.slice() - .sort((a, b) => b.netAmount.usd - a.netAmount.usd), - [query.data?.nftOffers] - ); - - const sortedByFloorDifference = useMemo( - () => - query.data?.nftOffers - ?.slice() - .sort( - (a, b) => b.floorDifferencePercentage - a.floorDifferencePercentage - ), - [query.data?.nftOffers] - ); - - const sortedByDate = useMemo( - () => - query.data?.nftOffers?.slice().sort((a, b) => b.createdAt - a.createdAt), - [query.data?.nftOffers] + const sortedOffers = sortNftOffers( + query.data?.nftOffers || [], + sortCriterion ); - let sortedOffers; - switch (sortCriterion) { - case SortCriterion.TopBidValue: - sortedOffers = sortedByValue; - break; - case SortCriterion.FloorDifferencePercentage: - sortedOffers = sortedByFloorDifference; - break; - case SortCriterion.DateCreated: - sortedOffers = sortedByDate; - break; - default: - sortedOffers = query.data?.nftOffers; - } - useEffect(() => { const nftOffers = query.data?.nftOffers ?? []; const totalUSDValue = nftOffers.reduce( diff --git a/src/screens/ChangeWalletSheet.tsx b/src/screens/ChangeWalletSheet.tsx index 9bdf9dde1e1..5da48857731 100644 --- a/src/screens/ChangeWalletSheet.tsx +++ b/src/screens/ChangeWalletSheet.tsx @@ -21,7 +21,7 @@ import { } from '../redux/wallets'; import { analytics, analyticsV2 } from '@/analytics'; import { getExperimetalFlag, HARDWARE_WALLETS } from '@/config'; -import { runCampaignChecks } from '@/campaigns/campaignChecks'; +import { useRemotePromoSheetContext } from '@/components/remote-promo-sheet/RemotePromoSheetProvider'; import { useAccountSettings, useInitializeWallet, @@ -115,6 +115,7 @@ export default function ChangeWalletSheet() { const { params = {} as any } = useRoute(); const { onChangeWallet, watchOnly = false, currentAccountAddress } = params; const { selectedWallet, wallets } = useWallets(); + const { runChecks } = useRemotePromoSheetContext(); const { colors } = useTheme(); const { updateWebProfile } = useWebData(); @@ -171,11 +172,7 @@ export default function ChangeWalletSheet() { goBack(); if (IS_TESTING !== 'true') { - InteractionManager.runAfterInteractions(() => { - setTimeout(async () => { - await runCampaignChecks(); - }, 5000); - }); + runChecks(); } } } catch (e) { diff --git a/src/screens/NFTOffersSheet/index.tsx b/src/screens/NFTOffersSheet/index.tsx index 0615fe8f386..fac04fa5a81 100644 --- a/src/screens/NFTOffersSheet/index.tsx +++ b/src/screens/NFTOffersSheet/index.tsx @@ -210,7 +210,7 @@ export const NFTOffersSheet = () => { ) { queryClient.invalidateQueries({ queryKey: nftOffersQueryKey({ - address: accountAddress, + walletAddress: accountAddress, }), }); } diff --git a/src/screens/NFTSingleOfferSheet/index.tsx b/src/screens/NFTSingleOfferSheet/index.tsx index 5f3c1a2b7f6..69a7688f0e8 100644 --- a/src/screens/NFTSingleOfferSheet/index.tsx +++ b/src/screens/NFTSingleOfferSheet/index.tsx @@ -391,7 +391,7 @@ export function NFTSingleOfferSheet() { // remove offer from cache queryClient.setQueryData( - nftOffersQueryKey({ address: accountAddress }), + nftOffersQueryKey({ walletAddress: accountAddress }), ( cachedData: { nftOffers: NftOffer[] | undefined } | undefined ) => { diff --git a/src/screens/NotificationsPromoSheet/index.tsx b/src/screens/NotificationsPromoSheet/index.tsx index 87f8272532a..4a35d45abf0 100644 --- a/src/screens/NotificationsPromoSheet/index.tsx +++ b/src/screens/NotificationsPromoSheet/index.tsx @@ -3,7 +3,7 @@ import * as perms from 'react-native-permissions'; import useAppState from '@/hooks/useAppState'; import { useNavigation } from '@/navigation/Navigation'; -import { CampaignKey } from '@/campaigns/campaignChecks'; +import { CampaignKey } from '@/components/remote-promo-sheet/localCampaignChecks'; import { PromoSheet } from '@/components/PromoSheet'; import backgroundImage from '@/assets/notificationsPromoSheetBackground.png'; import headerImageIOS from '@/assets/notificationsPromoSheetHeaderIOS.png'; diff --git a/src/screens/SettingsSheet/components/SettingsSection.tsx b/src/screens/SettingsSheet/components/SettingsSection.tsx index 46247804fdb..2a7efa07225 100644 --- a/src/screens/SettingsSheet/components/SettingsSection.tsx +++ b/src/screens/SettingsSheet/components/SettingsSection.tsx @@ -40,7 +40,6 @@ import { } from '@/utils/buildRainbowUrl'; import { getNetworkObj } from '@/networks'; import { handleReviewPromptAction } from '@/utils/reviewAlert'; -import * as ls from '@/storage'; import { ReviewPromptAction } from '@/storage/schema'; const SettingsExternalURLs = { diff --git a/src/screens/SwapsPromoSheet.tsx b/src/screens/SwapsPromoSheet.tsx deleted file mode 100644 index 47b427433ec..00000000000 --- a/src/screens/SwapsPromoSheet.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { useCallback } from 'react'; - -import { useNavigation } from '@/navigation/Navigation'; -import { CampaignKey } from '@/campaigns/campaignChecks'; -import { PromoSheet } from '@/components/PromoSheet'; -import { CurrencySelectionTypes, ExchangeModalTypes } from '@/helpers'; -import { useSwapCurrencyHandlers } from '@/hooks'; -import SwapsPromoBackground from '@/assets/swapsPromoBackground.png'; -import SwapsPromoHeader from '@/assets/swapsPromoHeader.png'; -import { delay } from '@/helpers/utilities'; -import Routes from '@/navigation/routesNames'; -import { useTheme } from '@/theme'; -import * as i18n from '@/languages'; - -const HEADER_HEIGHT = 285; -const HEADER_WIDTH = 390; - -export default function SwapsPromoSheet() { - const { colors } = useTheme(); - const { goBack, navigate } = useNavigation(); - const { updateInputCurrency } = useSwapCurrencyHandlers({ - shouldUpdate: false, - type: ExchangeModalTypes.swap, - }); - const translations = i18n.l.promos.swaps_launch; - - const navigateToSwaps = useCallback(() => { - goBack(); - delay(300).then(() => - navigate(Routes.EXCHANGE_MODAL, { - fromDiscover: true, - params: { - fromDiscover: true, - onSelectCurrency: updateInputCurrency, - title: i18n.t(i18n.l.swap.modal_types.swap), - type: CurrencySelectionTypes.input, - }, - screen: Routes.CURRENCY_SELECT_SCREEN, - }) - ); - }, [goBack, navigate, updateInputCurrency]); - - return ( - - ); -} diff --git a/src/storage/index.ts b/src/storage/index.ts index f3672850416..57255e5c1a8 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -1,6 +1,6 @@ import { MMKV } from 'react-native-mmkv'; -import { Account, Device, Review } from '@/storage/schema'; +import { Account, Campaigns, Device, Review } from '@/storage/schema'; import { EthereumAddress } from '@/entities'; import { Network } from '@/networks/types'; @@ -78,3 +78,5 @@ export const account = new Storage<[EthereumAddress, Network], Account>({ }); export const review = new Storage<[], Review>({ id: 'review' }); + +export const campaigns = new Storage<[], Campaigns>({ id: 'campaigns' }); diff --git a/src/storage/schema.ts b/src/storage/schema.ts index b93fc2bf0d8..e0cdf087c14 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -66,3 +66,14 @@ export type Review = { timeOfLastPrompt: number; actions: Action[]; }; + +type CampaignKeys = { + [campaignKey: string]: boolean; +}; + +type CampaignMetadata = { + isCurrentlyShown: boolean; + lastShownTimestamp: number; +}; + +export type Campaigns = CampaignKeys & CampaignMetadata; diff --git a/src/utils/index.ts b/src/utils/index.ts index e54baafd14d..3d3420acbb1 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -50,3 +50,4 @@ export { default as withSpeed } from './withSpeed'; export { default as CoinIcon } from './CoinIcons/CoinIcon'; export { default as FallbackIcon } from './CoinIcons/FallbackIcon'; export { default as getExchangeIconUrl } from './getExchangeIconUrl'; +export { resolveFirstRejectLast } from './resolveFirstRejectLast'; diff --git a/src/utils/resolveFirstRejectLast.ts b/src/utils/resolveFirstRejectLast.ts new file mode 100644 index 00000000000..ca92783b9f0 --- /dev/null +++ b/src/utils/resolveFirstRejectLast.ts @@ -0,0 +1,48 @@ +import { forEach } from 'lodash'; + +/** + * Resolve the first Promise, Reject when all have failed + * + * This method accepts a list of promises and has them + * compete in a horserace to determine which promise can + * resolve first (similar to Promise.race). However, this + * method differs why waiting to reject until ALL promises + * have rejected, rather than waiting for the first. + * + * The return of this method is a promise that either resolves + * to the first promises resolved value, or rejects with an arra + * of errors (with indexes corresponding to the promises). + * + * @param {List} promises list of promises to run + */ +export type Status = { + winner: T | null; + errors: Array; +}; + +export const resolveFirstRejectLast = (promises: Array>) => { + return new Promise((resolve, reject) => { + let errorCount = 0; + const status: Status = { + winner: null, + errors: new Array(promises.length), + }; + forEach(promises, (p, idx) => { + p.then( + resolved => { + if (!status.winner) { + status.winner = resolved; + resolve(resolved); + } + }, + error => { + status.errors[idx] = error; + errorCount += 1; + if (errorCount >= status.errors.length && !status.winner) { + reject(status.errors); + } + } + ); + }); + }); +}; From f2eebc5ff68c592a3a4c2a0f39a32a5790ee2070 Mon Sep 17 00:00:00 2001 From: Matthew Wall Date: Wed, 15 Nov 2023 08:19:54 -0700 Subject: [PATCH 16/16] [PATCH]: react-native-fast-image iOS 17 spec (#5188) * fix: patch rnfi to iOS 17 spec * add initial and new patch * delete original patch file * add nl char --- ...ive-fast-image+8.5.11+fix-make-image.patch | 23 +++++++++++++++++++ ...ct-native-fast-image+8.5.11+initial.patch} | 0 2 files changed, 23 insertions(+) create mode 100644 patches/react-native-fast-image+8.5.11+fix-make-image.patch rename patches/{react-native-fast-image+8.5.11.patch => react-native-fast-image+8.5.11+initial.patch} (100%) diff --git a/patches/react-native-fast-image+8.5.11+fix-make-image.patch b/patches/react-native-fast-image+8.5.11+fix-make-image.patch new file mode 100644 index 00000000000..05d872989f9 --- /dev/null +++ b/patches/react-native-fast-image+8.5.11+fix-make-image.patch @@ -0,0 +1,23 @@ +diff --git a/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m b/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m +index 9c0f1d3..db4da88 100644 +--- a/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m ++++ b/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m +@@ -70,12 +70,12 @@ - (void)setImageColor:(UIColor *)imageColor { + } + + - (UIImage*)makeImage:(UIImage *)image withTint:(UIColor *)color { +- UIImage *newImage = [image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; +- UIGraphicsBeginImageContextWithOptions(image.size, NO, newImage.scale); +- [color set]; +- [newImage drawInRect:CGRectMake(0, 0, image.size.width, newImage.size.height)]; +- newImage = UIGraphicsGetImageFromCurrentImageContext(); +- UIGraphicsEndImageContext(); ++ UIImage* newImage = [image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; ++ UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:image.size]; ++ newImage = [renderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull rendererContext) { ++ [color setFill]; ++ [newImage drawInRect:CGRectMake(0, 0, image.size.width, newImage.size.height)]; ++ }]; + return newImage; + } + diff --git a/patches/react-native-fast-image+8.5.11.patch b/patches/react-native-fast-image+8.5.11+initial.patch similarity index 100% rename from patches/react-native-fast-image+8.5.11.patch rename to patches/react-native-fast-image+8.5.11+initial.patch