From 1f4bfa056b4bdb8dcfd8243ffa79ecfd5adf15d0 Mon Sep 17 00:00:00 2001 From: Matthew Wall Date: Tue, 9 Apr 2024 09:47:47 -0600 Subject: [PATCH 1/5] fix scrolltoindex firing on last card dismissal (#5606) --- src/components/cards/remote-cards/RemoteCard.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/cards/remote-cards/RemoteCard.tsx b/src/components/cards/remote-cards/RemoteCard.tsx index 6eb592c33fd..6392dfce8bd 100644 --- a/src/components/cards/remote-cards/RemoteCard.tsx +++ b/src/components/cards/remote-cards/RemoteCard.tsx @@ -97,11 +97,17 @@ export const RemoteCard: React.FC = ({ card = {} as TrimmedCard analyticsV2.track(analyticsV2.event.remoteCardDismissed, { cardKey: cardKey ?? 'unknown-backend-driven-card', }); + + const isLastCard = cards.length === 1; + dismissCard(card.sys.id); if (carouselRef?.current) { const currentCardIdx = cards.findIndex(c => c.cardKey === cardKey); if (currentCardIdx === -1) return; + // check if this is the last card and don't scroll if so + if (isLastCard) return; + carouselRef.current.scrollToIndex({ index: currentCardIdx, animated: true, From 81438bea8960c543a84ae8315022ae190a63b596 Mon Sep 17 00:00:00 2001 From: Matthew Wall Date: Tue, 9 Apr 2024 09:47:57 -0600 Subject: [PATCH 2/5] improve type checking on web preferences (#5607) --- src/hooks/useWebData.ts | 31 ++++++------ src/model/preferences.ts | 106 +++++++++++++++++++++++++++++---------- 2 files changed, 96 insertions(+), 41 deletions(-) diff --git a/src/hooks/useWebData.ts b/src/hooks/useWebData.ts index f00bbd06d93..daa5a80b5ad 100644 --- a/src/hooks/useWebData.ts +++ b/src/hooks/useWebData.ts @@ -85,6 +85,10 @@ export default function useWebData() { const getWebProfile = useCallback(async (address: string) => { const response = address && (await getPreference('profile', address)); + if (!response) { + return null; + } + return response?.profile; }, []); @@ -92,14 +96,13 @@ export default function useWebData() { async (assetIds: any) => { if (!webDataEnabled) return; const response = await getPreference('showcase', accountAddress); - // If the showcase is populated, just updated it - if (response?.ids?.length > 0) { - setPreference(PreferenceActionType.update, 'showcase', accountAddress, assetIds); - } else { - // Initialize showcase and profiles + if (!response || !response.showcase.ids.length) { await initWebData(assetIds); logger.log('showcase initialized!'); + return; } + + setPreference(PreferenceActionType.update, 'showcase', accountAddress, assetIds); }, [accountAddress, initWebData, webDataEnabled] ); @@ -107,14 +110,13 @@ export default function useWebData() { const updateWebHidden = useCallback( async (assetIds: any) => { const response = await getPreference('hidden', accountAddress); - // If the showcase is populated, just updated it - if (response?.ids?.length > 0) { - setPreference(PreferenceActionType.update, 'hidden', accountAddress, assetIds); - } else { + if (!response || !response.hidden.ids.length) { await setPreference(PreferenceActionType.init, 'hidden', accountAddress, assetIds); - logger.log('hidden initialized!'); + return; } + + setPreference(PreferenceActionType.update, 'hidden', accountAddress, assetIds); }, [accountAddress] ); @@ -126,14 +128,13 @@ export default function useWebData() { // If webdata is enabled if (webDataEnabled) { const response = await getPreference('showcase', accountAddress); - // If the showcase is populated, nothing to do - if (response?.ids?.length > 0) { - logger.log('showcase already initialized. skipping'); - } else { - // Initialize + if (!response || !response.showcase.ids.length) { await initWebData(showcaseTokens); logger.log('showcase initialized!'); + return; } + + logger.log('showcase already initialized. skipping'); } } } catch (e) { diff --git a/src/model/preferences.ts b/src/model/preferences.ts index 92311f9e053..226424912e2 100644 --- a/src/model/preferences.ts +++ b/src/model/preferences.ts @@ -2,6 +2,16 @@ import { RainbowFetchClient } from '../rainbow-fetch'; import { EthereumAddress } from '@/entities'; import { getSignatureForSigningWalletAndCreateSignatureIfNeeded, signWithSigningWallet } from '@/helpers/signingWallet'; import { logger } from '@/logger'; +import { Address } from 'viem'; + +export const PREFS_ENDPOINT = 'https://api.rainbow.me'; +const preferencesAPI = new RainbowFetchClient({ + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + timeout: 30000, // 30 secs +}); export enum PreferenceActionType { update = 'update', @@ -10,27 +20,60 @@ export enum PreferenceActionType { init = 'init', } -export interface PreferencesResponse { - success: boolean; - reason: string; - data?: Record | undefined; +export enum PreferenceKeys { + showcase = 'showcase', + profile = 'profile', } -export const PREFS_ENDPOINT = 'https://api.rainbow.me'; +type TokenContract = Address; +type TokenId = string; -const preferencesAPI = new RainbowFetchClient({ - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }, - timeout: 30000, // 30 secs -}); +type TokenContractWithId = `${TokenContract}_${TokenId}`; + +type HiddenPreferencesData = { + hidden: { + ids: []; + }; +}; + +type ShowcasePreferencesData = { + showcase: { + ids: TokenContractWithId[]; + }; +}; + +type Profile = { + accountColor: string; + accountSymbol: string | null; +}; + +type ProfilePreferencesData = { + profile: Profile; +}; + +type PreferencesDataMap = { + showcase: ShowcasePreferencesData; + profile: ProfilePreferencesData; + hidden: HiddenPreferencesData; +}; + +type PayloadMap = { + showcase: string[]; + profile: Profile; + hidden: string[]; +}; -export async function setPreference( +type PreferencesResponse = { + success: boolean; + data?: T extends keyof PreferencesDataMap ? PreferencesDataMap[T] : never; + reason?: string; +}; + +export async function setPreference( action: PreferenceActionType, - key: string, + key: K, address: EthereumAddress, - value?: any | undefined + value?: PayloadMap[K] ): Promise { try { const signature = await getSignatureForSigningWalletAndCreateSignatureIfNeeded(address); @@ -46,17 +89,21 @@ export async function setPreference( const message = JSON.stringify(objToSign); const signature2 = await signWithSigningWallet(message); logger.debug('☁️ SENDING ', { message }); - const response = await preferencesAPI.post(`${PREFS_ENDPOINT}/${key}`, { + const { data } = await preferencesAPI.post>(`${PREFS_ENDPOINT}/${key}`, { message, signature, signature2, }); - const responseData: PreferencesResponse = response.data as PreferencesResponse; logger.debug('☁️ RESPONSE', { - reason: responseData?.reason, - success: responseData?.success, + reason: data?.reason, + success: data?.success, }); - return responseData?.success; + + if (!data.data) { + throw new Error('Failed to set preference'); + } + + return data?.success; } catch (e) { logger.warn(`Preferences API failed to set preference`, { preferenceKey: key, @@ -65,17 +112,24 @@ export async function setPreference( } } -export async function getPreference(key: string, address: EthereumAddress): Promise { +export async function getPreference( + key: K, + address: EthereumAddress +): Promise { try { - const response = await preferencesAPI.get(`${PREFS_ENDPOINT}/${key}`, { + const { data } = await preferencesAPI.get>(`${PREFS_ENDPOINT}/${key}`, { params: { address }, }); - const responseData: PreferencesResponse = response.data as PreferencesResponse; logger.debug('☁️ RESPONSE', { - reason: responseData?.reason, - success: responseData?.success, + reason: data?.reason, + success: data?.success, }); - return responseData?.data || null; + + if (!data.data) { + return null; + } + + return data.data; } catch (e) { logger.warn(`Preferences API failed to get preference`, { preferenceKey: key, From 8decc87da310ce6142f20f243e4e329c184b447a Mon Sep 17 00:00:00 2001 From: Bruno Barbieri <1247834+brunobar79@users.noreply.github.com> Date: Tue, 9 Apr 2024 12:18:57 -0400 Subject: [PATCH 3/5] fix cloudflare protection (#5609) --- src/components/DappBrowser/BrowserTab.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/DappBrowser/BrowserTab.tsx b/src/components/DappBrowser/BrowserTab.tsx index 4c0a04f0227..f0a8fbba6b2 100644 --- a/src/components/DappBrowser/BrowserTab.tsx +++ b/src/components/DappBrowser/BrowserTab.tsx @@ -795,6 +795,7 @@ export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, inje onLoadProgress={handleOnLoadProgress} onMessage={handleOnMessage} onNavigationStateChange={handleNavigationStateChange} + originWhitelist={['*']} ref={webViewRef} source={{ uri: tabUrl || RAINBOW_HOME }} style={styles.webViewStyle} From cfae287f1a8177ba624d74ab88d1507baa108e19 Mon Sep 17 00:00:00 2001 From: Derek Nelson <6868432+dereknelson@users.noreply.github.com> Date: Tue, 9 Apr 2024 13:18:53 -0400 Subject: [PATCH 4/5] added warning for unknown price impact (#5597) * added warning for unknown price impact * more concise price impact warning * prop drilling * added common import * price impact warning * null safety --- .../exchange/ExchangeDetailsRow.tsx | 3 ++ .../exchange/PriceImpactWarning.tsx | 31 +++++++++----- .../expanded-state/SwapDetailsState.js | 1 + .../SwapDetailsSlippageMessage.js | 40 +++++++++++++------ src/hooks/usePriceImpactDetails.ts | 1 + src/languages/en_US.json | 4 +- src/screens/ExchangeModal.tsx | 7 ++-- 7 files changed, 61 insertions(+), 26 deletions(-) diff --git a/src/components/exchange/ExchangeDetailsRow.tsx b/src/components/exchange/ExchangeDetailsRow.tsx index acaf5dffbc7..fa38179855e 100644 --- a/src/components/exchange/ExchangeDetailsRow.tsx +++ b/src/components/exchange/ExchangeDetailsRow.tsx @@ -21,6 +21,7 @@ interface ExchangeDetailsRowProps { priceImpactColor?: string; priceImpactNativeAmount?: string | null; priceImpactPercentDisplay?: string | null; + outputCurrencySymbol?: string | null; type: string; } @@ -32,6 +33,7 @@ export default function ExchangeDetailsRow({ priceImpactColor, priceImpactNativeAmount, priceImpactPercentDisplay, + outputCurrencySymbol, type, }: ExchangeDetailsRowProps) { const detailsRowOpacity = useSharedValue(1); @@ -83,6 +85,7 @@ export default function ExchangeDetailsRow({ priceImpactColor={priceImpactColor} priceImpactNativeAmount={priceImpactNativeAmount} priceImpactPercentDisplay={priceImpactPercentDisplay} + outputCurrencySymbol={outputCurrencySymbol} style={priceImpactAnimatedStyle} /> void; @@ -12,6 +13,7 @@ interface PriceImpactWarningProps extends ViewProps { priceImpactColor?: string; priceImpactNativeAmount?: string | null; priceImpactPercentDisplay?: string | null; + outputCurrencySymbol?: string | null; style?: StyleProp; } @@ -21,29 +23,38 @@ export default function PriceImpactWarning({ priceImpactColor = 'primary', priceImpactNativeAmount, priceImpactPercentDisplay, + outputCurrencySymbol, style, ...props }: PriceImpactWarningProps) { const headingValue = priceImpactNativeAmount ?? priceImpactPercentDisplay; + const hasPriceData = priceImpactPercentDisplay !== NO_PRICE_DATA_PERCENTAGE; + const impactMsg = !hasPriceData + ? `${outputCurrencySymbol} ${lang.t('exchange.price_impact.no_data')}` + : lang.t('exchange.price_impact.small_market'); return ( - {isHighPriceImpact && headingValue && ( + {!isHighPriceImpact && headingValue && ( {`􀇿 `} - {lang.t('exchange.price_impact.small_market')} - - {` • ${lang.t('exchange.price_impact.losing_prefix')} `} - - {headingValue} + {impactMsg} + {hasPriceData && ( + {` • ${lang.t('exchange.price_impact.losing_prefix')} `} + )} + {hasPriceData && ( + + {headingValue} + + )} diff --git a/src/components/expanded-state/SwapDetailsState.js b/src/components/expanded-state/SwapDetailsState.js index 3ae4870636e..d7c18c6ba26 100644 --- a/src/components/expanded-state/SwapDetailsState.js +++ b/src/components/expanded-state/SwapDetailsState.js @@ -149,6 +149,7 @@ export default function SwapDetailsState({ confirmButtonProps, restoreFocusOnSwa /> - - - - {lang.t('expanded_state.swap.losing')}{' '} - - - {headingValue} - - 🥵 - - {lang.t('expanded_state.swap.slippage_message')} - + {hasPriceData ? ( + + + + {lang.t('expanded_state.swap.losing')}{' '} + + + {headingValue} + + 🥵 + + {lang.t('expanded_state.swap.slippage_message')} + + ) : ( + + + {`􀇿 `} + + {impactMsg} + + + {lang.t('exchange.price_impact.no_data_subtitle')} + + )} diff --git a/src/hooks/usePriceImpactDetails.ts b/src/hooks/usePriceImpactDetails.ts index 0e5656428d6..d359fff1a88 100644 --- a/src/hooks/usePriceImpactDetails.ts +++ b/src/hooks/usePriceImpactDetails.ts @@ -25,6 +25,7 @@ export enum SwapPriceImpactType { const PriceImpactWarningThreshold = 0.05; const SeverePriceImpactThreshold = 0.1; +export const NO_PRICE_DATA_PERCENTAGE = '100.00%'; export default function usePriceImpactDetails( inputCurrency: SwappableAsset | null, diff --git a/src/languages/en_US.json b/src/languages/en_US.json index 590dd54618e..c4f7b2740b8 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -564,7 +564,9 @@ "price_impact": { "losing_prefix": "Losing", "small_market": "Small Market", - "label": "Possible loss" + "no_data": "Market Value Unknown", + "label": "Possible loss", + "no_data_subtitle": "If you decide to continue, be sure that you are satisfied with the quoted amount" }, "source": { "rainbow": "Auto", diff --git a/src/screens/ExchangeModal.tsx b/src/screens/ExchangeModal.tsx index 7dc37d3baed..aa3c2a97c5d 100644 --- a/src/screens/ExchangeModal.tsx +++ b/src/screens/ExchangeModal.tsx @@ -681,9 +681,9 @@ export default function ExchangeModal({ fromDiscover, ignoreInitialTypeCheck, te android && Keyboard.dismiss(); const lastFocusedInputHandleTemporary = lastFocusedInputHandle.current; android && (lastFocusedInputHandle.current = null); - inputFieldRef?.current?.blur(); - outputFieldRef?.current?.blur(); - nativeFieldRef?.current?.blur(); + inputFieldRef?.current?.blur?.(); + outputFieldRef?.current?.blur?.(); + nativeFieldRef?.current?.blur?.(); const internalNavigate = () => { IS_ANDROID && keyboardListenerSubscription.current?.remove(); setParams({ focused: false }); @@ -847,6 +847,7 @@ export default function ExchangeModal({ fromDiscover, ignoreInitialTypeCheck, te isHighPriceImpact={ !confirmButtonProps.disabled && !confirmButtonProps.loading && debouncedIsHighPriceImpact && isSufficientBalance } + outputCurrencySymbol={outputCurrency?.symbol} onFlipCurrencies={loading ? NOOP : flipCurrencies} onPressImpactWarning={navigateToSwapDetailsModal} onPressSettings={navigateToSwapSettingsSheet} From 0cc76b55aedcfc48628859aafa357fe81418e056 Mon Sep 17 00:00:00 2001 From: Bruno Barbieri <1247834+brunobar79@users.noreply.github.com> Date: Tue, 9 Apr 2024 15:36:45 -0400 Subject: [PATCH 5/5] allow open in new tab (#5610) --- src/components/DappBrowser/BrowserContext.tsx | 52 ++++++++++--------- src/components/DappBrowser/DappBrowser.tsx | 28 ++++++++-- 2 files changed, 53 insertions(+), 27 deletions(-) diff --git a/src/components/DappBrowser/BrowserContext.tsx b/src/components/DappBrowser/BrowserContext.tsx index d56ddb0c75d..2e2d645513d 100644 --- a/src/components/DappBrowser/BrowserContext.tsx +++ b/src/components/DappBrowser/BrowserContext.tsx @@ -46,7 +46,7 @@ interface BrowserContextType { goBack: () => void; goForward: () => void; loadProgress: SharedValue | undefined; - newTabWorklet: () => void; + newTabWorklet: (url?: string) => void; onRefresh: () => void; searchInputRef: React.RefObject; searchViewProgress: SharedValue | undefined; @@ -74,6 +74,7 @@ interface TabOperation { type: TabOperationType; tabId: string; newActiveIndex: number | undefined; + url?: string; } export const RAINBOW_HOME = 'RAINBOW_HOME'; @@ -107,7 +108,7 @@ const DEFAULT_BROWSER_CONTEXT: BrowserContextType = { goForward: () => { return; }, - newTabWorklet: () => { + newTabWorklet: (url?: string) => { return; }, onRefresh: () => { @@ -311,7 +312,7 @@ export const BrowserContextProvider = ({ children }: { children: React.ReactNode canGoBack: false, canGoForward: false, uniqueId: operation.tabId, - url: RAINBOW_HOME, + url: operation.url || RAINBOW_HOME, }; newTabStates.push(newTab); shouldToggleTabView = true; @@ -357,27 +358,30 @@ export const BrowserContextProvider = ({ children }: { children: React.ReactNode tabViewVisible, ]); - const newTabWorklet = useCallback(() => { - 'worklet'; - const tabIdsInStates = new Set(tabStates?.map(state => state.uniqueId)); - const isNewTabOperationPending = - tabOperationQueue.value.some(operation => operation.type === 'newTab') || - currentlyOpenTabIds.value.some(tabId => !tabIdsInStates.has(tabId)); - - // The first check is mainly to guard against an edge case that happens when the new tab button is - // pressed just after the last tab is closed, but before a new blank tab has opened programatically, - // which results in two tabs being created when the user most certainly only wanted one. - if (!isNewTabOperationPending && (tabViewVisible.value || currentlyOpenTabIds.value.length === 0)) { - const tabIdForNewTab = generateUniqueIdWorklet(); - const newActiveIndex = currentlyOpenTabIds.value.length - 1; - - currentlyOpenTabIds.modify(value => { - value.push(tabIdForNewTab); - return value; - }); - requestTabOperationsWorklet({ type: 'newTab', tabId: tabIdForNewTab, newActiveIndex }); - } - }, [currentlyOpenTabIds, requestTabOperationsWorklet, tabOperationQueue, tabStates, tabViewVisible]); + const newTabWorklet = useCallback( + (url?: string) => { + 'worklet'; + const tabIdsInStates = new Set(tabStates?.map(state => state.uniqueId)); + const isNewTabOperationPending = + tabOperationQueue.value.some(operation => operation.type === 'newTab') || + currentlyOpenTabIds.value.some(tabId => !tabIdsInStates.has(tabId)); + + // The first check is mainly to guard against an edge case that happens when the new tab button is + // pressed just after the last tab is closed, but before a new blank tab has opened programatically, + // which results in two tabs being created when the user most certainly only wanted one. + if (url || (!isNewTabOperationPending && (tabViewVisible.value || currentlyOpenTabIds.value.length === 0))) { + const tabIdForNewTab = generateUniqueIdWorklet(); + const newActiveIndex = currentlyOpenTabIds.value.length - 1; + + currentlyOpenTabIds.modify(value => { + value.push(tabIdForNewTab); + return value; + }); + requestTabOperationsWorklet({ type: 'newTab', tabId: tabIdForNewTab, newActiveIndex, url }); + } + }, + [currentlyOpenTabIds, requestTabOperationsWorklet, tabOperationQueue, tabStates, tabViewVisible] + ); const closeTabWorklet = useCallback( (tabId: string, tabIndex: number) => { diff --git a/src/components/DappBrowser/DappBrowser.tsx b/src/components/DappBrowser/DappBrowser.tsx index 7b1cc040423..ee706166e73 100644 --- a/src/components/DappBrowser/DappBrowser.tsx +++ b/src/components/DappBrowser/DappBrowser.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { StyleSheet } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; -import Animated, { interpolateColor, useAnimatedProps, useAnimatedStyle } from 'react-native-reanimated'; +import Animated, { interpolateColor, runOnJS, useAnimatedProps, useAnimatedReaction, useAnimatedStyle } from 'react-native-reanimated'; import RNFS from 'react-native-fs'; import { Page } from '@/components/layout'; @@ -15,6 +15,7 @@ import { Search } from './search/Search'; import { TabViewToolbar } from './TabViewToolbar'; import { SheetGestureBlocker } from '../sheet/SheetGestureBlocker'; import { ProgressBar } from './ProgressBar'; +import { RouteProp, useRoute } from '@react-navigation/native'; const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView); @@ -28,10 +29,33 @@ const getInjectedJS = async () => { } }; +export type DappBrowserParams = { + url: string; +}; + +type RouteParams = { + DappBrowserParams: DappBrowserParams; +}; + const DappBrowserComponent = () => { const { isDarkMode } = useColorMode(); const [injectedJS, setInjectedJS] = useState(''); + const { scrollViewRef, tabStates, tabViewProgress, tabViewVisible, newTabWorklet, toggleTabViewWorklet } = useBrowserContext(); + + const route = useRoute>(); + + useAnimatedReaction( + () => route.params?.url, + (current, previous) => { + if (current !== previous && route.params?.url) { + newTabWorklet(current); + toggleTabViewWorklet(); + } + }, + [newTabWorklet, route.params?.url] + ); + useEffect(() => { const loadInjectedJS = async () => { try { @@ -44,8 +68,6 @@ const DappBrowserComponent = () => { loadInjectedJS(); }, []); - const { scrollViewRef, tabStates, tabViewProgress, tabViewVisible } = useBrowserContext(); - useEffect(() => { pruneScreenshots(tabStates); // eslint-disable-next-line react-hooks/exhaustive-deps