diff --git a/.gitignore b/.gitignore index 9c7115fcc81..1cb6d26af43 100644 --- a/.gitignore +++ b/.gitignore @@ -92,8 +92,7 @@ ios/Extras.json rainbow-scripts # Patches -patches/react-native-webview+13.2.3.patch -patches/react-native-webview+13.7.0.patch +patches/react-native-webview* .eslintcache diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 998b00081bb..fad16b5826b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -11,6 +11,14 @@ PODS: - React-Core - SSZipArchive (~> 2.2.2) - DoubleConversion (1.1.6) + - FasterImage (1.3.4): + - FasterImage/Nuke (= 1.3.4) + - FasterImage/NukeUI (= 1.3.4) + - React-Core + - FasterImage/Nuke (1.3.4): + - React-Core + - FasterImage/NukeUI (1.3.4): + - React-Core - FBLazyVector (0.72.3) - FBReactNativeSpec (0.72.3): - RCT-Folly (= 2021.07.22.00) @@ -581,7 +589,7 @@ PODS: - React-Core - react-native-view-shot (3.8.0): - React-Core - - react-native-webview (13.7.0): + - react-native-webview (13.8.2): - RCT-Folly (= 2021.07.22.00) - React-Core - react-native-widgetkit (1.0.9): @@ -824,6 +832,7 @@ DEPENDENCIES: - BVLinearGradient (from `../node_modules/react-native-linear-gradient`) - CodePush (from `../node_modules/react-native-code-push`) - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) + - "FasterImage (from `../node_modules/@candlefinance/faster-image`)" - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - FBReactNativeSpec (from `../node_modules/react-native/React/FBReactNativeSpec`) - Firebase @@ -1004,6 +1013,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-code-push" DoubleConversion: :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" + FasterImage: + :path: "../node_modules/@candlefinance/faster-image" FBLazyVector: :path: "../node_modules/react-native/Libraries/FBLazyVector" FBReactNativeSpec: @@ -1242,6 +1253,7 @@ SPEC CHECKSUMS: CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 CodePush: 9eddecce05cd10491e2673b259c85885a462be33 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 + FasterImage: 283258273b862374eb2ede3ef9576149fd83deb3 FBLazyVector: 4cce221dd782d3ff7c4172167bba09d58af67ccb FBReactNativeSpec: c6bd9e179757b3c0ecf815864fae8032377903ef Firebase: 07150e75d142fb9399f6777fa56a187b17f833a0 @@ -1324,7 +1336,7 @@ SPEC CHECKSUMS: react-native-version-number: b415bbec6a13f2df62bf978e85bc0d699462f37f react-native-video: c26780b224543c62d5e1b2a7244a5cd1b50e8253 react-native-view-shot: 6b7ed61d77d88580fed10954d45fad0eb2d47688 - react-native-webview: 4e7d637b43eddec107016d316ae75f7063a3075c + react-native-webview: 19aba9db98e183484969202c1181a00159b7724b react-native-widgetkit: efb6680df237463bbe1be3a4d1a1578a1b0bb08f React-NativeModulesApple: c57f3efe0df288a6532b726ad2d0322a9bf38472 React-perflogger: 6bd153e776e6beed54c56b0847e1220a3ff92ba5 diff --git a/package.json b/package.json index 21d1fab3961..10c252fad5d 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "dependencies": { "@bankify/react-native-animate-number": "0.2.1", "@bradgarropy/use-countdown": "1.4.1", + "@candlefinance/faster-image": "1.3.4", "@capsizecss/core": "3.0.0", "@ensdomains/address-encoder": "0.2.16", "@ensdomains/content-hash": "2.5.7", @@ -98,7 +99,7 @@ "@notifee/react-native": "5.6.0", "@rainbow-me/provider": "0.0.11", "@rainbow-me/react-native-animated-number": "0.0.2", - "@rainbow-me/swaps": "0.10.0", + "@rainbow-me/swaps": "0.12.0", "@react-native-async-storage/async-storage": "1.18.2", "@react-native-camera-roll/camera-roll": "5.7.1", "@react-native-clipboard/clipboard": "1.13.2", @@ -272,7 +273,7 @@ "react-native-video": "5.2.1", "react-native-view-shot": "3.8.0", "react-native-vision-camera": "3.6.4", - "react-native-webview": "13.7.0", + "react-native-webview": "13.8.2", "react-native-widgetkit": "1.0.9", "react-navigation-backhandler": "2.0.1", "react-primitives": "0.8.1", diff --git a/src/__swaps__/screens/Swap/components/GestureHandlerV1Button.tsx b/src/__swaps__/screens/Swap/components/GestureHandlerV1Button.tsx index 3482c9335e3..09d0a652164 100644 --- a/src/__swaps__/screens/Swap/components/GestureHandlerV1Button.tsx +++ b/src/__swaps__/screens/Swap/components/GestureHandlerV1Button.tsx @@ -1,15 +1,16 @@ +import ConditionalWrap from 'conditional-wrap'; import React from 'react'; import { StyleProp, ViewProps, ViewStyle } from 'react-native'; import { TapGestureHandler, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler'; -import Animated, { useAnimatedGestureHandler } from 'react-native-reanimated'; +import Animated, { runOnJS, useAnimatedGestureHandler } from 'react-native-reanimated'; import { ButtonPressAnimation } from '@/components/animations'; -import ConditionalWrap from 'conditional-wrap'; import { IS_IOS } from '@/env'; type GestureHandlerButtonProps = { children: React.ReactNode; disableButtonPressWrapper?: boolean; disabled?: boolean; + onPressJS?: () => void; onPressStartWorklet?: () => void; onPressWorklet?: () => void; pointerEvents?: ViewProps['pointerEvents']; @@ -18,13 +19,19 @@ type GestureHandlerButtonProps = { }; /** - * @description This button runs its press functions directly on the UI thread, - * which is useful when working with Reanimated, as it allows for the instant - * manipulation of shared values without any dependence on the JS thread. + * @description This button can execute press functions directly on the UI thread, + * which is useful when working with Reanimated, as it allows for instantly + * manipulating shared values without any dependence on the JS thread. * * 👉 Intended for use with react-native-gesture-handler v1 * - * Its onPress props accept worklets, which need to be tagged with `'worklet';` + * ——— + * + * 🔵 `onPressWorklet` + * - + * 🔵 `onPressStartWorklet` + * - + * - To execute code on the UI thread, pass a function tagged with `'worklet';` * like so: * * ``` @@ -33,11 +40,26 @@ type GestureHandlerButtonProps = { * opacity.value = withTiming(1); * }; * ``` + * ——— + * + * 🟢 `onPressJS` + * - + * - If you need to simultaneously execute code on the JS thread, rather than + * using runOnJS within your worklet, you can pass a function via `onPressJS`: + * + * ``` + * const [fromJSThread, setFromJSThread] = useState(false); + * + * const onPressJS = () => { + * setFromJSThread(true); + * }; + * ``` */ export function GestureHandlerV1Button({ children, disableButtonPressWrapper = false, disabled = false, + onPressJS, onPressStartWorklet, onPressWorklet, pointerEvents = 'box-only', @@ -50,24 +72,21 @@ export function GestureHandlerV1Button({ }, onActive: () => { if (onPressWorklet) onPressWorklet(); + if (onPressJS) runOnJS(onPressJS)(); }, }); return ( ( - + {children} )} > {/* @ts-expect-error Property 'children' does not exist on type */} - + {children} diff --git a/src/__swaps__/screens/Swap/components/SearchInput.tsx b/src/__swaps__/screens/Swap/components/SearchInput.tsx index 6ad9dc704b5..63bd415b792 100644 --- a/src/__swaps__/screens/Swap/components/SearchInput.tsx +++ b/src/__swaps__/screens/Swap/components/SearchInput.tsx @@ -60,7 +60,7 @@ export const SearchInput = ({ handleExitSearch(); setIsFocused(false); }} - onChange={(value: string) => setQuery(value)} + onChange={e => setQuery(e.nativeEvent.text)} onFocus={() => { handleFocusSearch(); setIsFocused(true); diff --git a/src/assets/BackedUpCloud.png b/src/assets/BackedUpCloud.png new file mode 100644 index 00000000000..10fb59ccd3a Binary files /dev/null and b/src/assets/BackedUpCloud.png differ diff --git a/src/assets/BackedUpCloud@2x.png b/src/assets/BackedUpCloud@2x.png new file mode 100644 index 00000000000..559629b7903 Binary files /dev/null and b/src/assets/BackedUpCloud@2x.png differ diff --git a/src/assets/BackedUpCloud@3x.png b/src/assets/BackedUpCloud@3x.png new file mode 100644 index 00000000000..f163e91ca3a Binary files /dev/null and b/src/assets/BackedUpCloud@3x.png differ diff --git a/src/assets/BackupWarning.png b/src/assets/BackupWarning.png new file mode 100644 index 00000000000..26b2efcec4d Binary files /dev/null and b/src/assets/BackupWarning.png differ diff --git a/src/assets/BackupWarning@2x.png b/src/assets/BackupWarning@2x.png new file mode 100644 index 00000000000..5323a789229 Binary files /dev/null and b/src/assets/BackupWarning@2x.png differ diff --git a/src/assets/BackupWarning@3x.png b/src/assets/BackupWarning@3x.png new file mode 100644 index 00000000000..c0653184df5 Binary files /dev/null and b/src/assets/BackupWarning@3x.png differ diff --git a/src/assets/CloudBackupWarning.png b/src/assets/CloudBackupWarning.png new file mode 100644 index 00000000000..049461c7af3 Binary files /dev/null and b/src/assets/CloudBackupWarning.png differ diff --git a/src/assets/CloudBackupWarning@2x.png b/src/assets/CloudBackupWarning@2x.png new file mode 100644 index 00000000000..f1894f9f5d7 Binary files /dev/null and b/src/assets/CloudBackupWarning@2x.png differ diff --git a/src/assets/CloudBackupWarning@3x.png b/src/assets/CloudBackupWarning@3x.png new file mode 100644 index 00000000000..4133d3d62e0 Binary files /dev/null and b/src/assets/CloudBackupWarning@3x.png differ diff --git a/src/assets/CreateNewWallet.png b/src/assets/CreateNewWallet.png new file mode 100644 index 00000000000..ccc74c01d1d Binary files /dev/null and b/src/assets/CreateNewWallet.png differ diff --git a/src/assets/CreateNewWallet@2x.png b/src/assets/CreateNewWallet@2x.png new file mode 100644 index 00000000000..387f14bfa46 Binary files /dev/null and b/src/assets/CreateNewWallet@2x.png differ diff --git a/src/assets/CreateNewWallet@3x.png b/src/assets/CreateNewWallet@3x.png new file mode 100644 index 00000000000..f40a606b06b Binary files /dev/null and b/src/assets/CreateNewWallet@3x.png differ diff --git a/src/assets/ImportSecretPhraseOrPrivateKey.png b/src/assets/ImportSecretPhraseOrPrivateKey.png new file mode 100644 index 00000000000..44c0a0ea59e Binary files /dev/null and b/src/assets/ImportSecretPhraseOrPrivateKey.png differ diff --git a/src/assets/ImportSecretPhraseOrPrivateKey@2x.png b/src/assets/ImportSecretPhraseOrPrivateKey@2x.png new file mode 100644 index 00000000000..475ee5bba99 Binary files /dev/null and b/src/assets/ImportSecretPhraseOrPrivateKey@2x.png differ diff --git a/src/assets/ImportSecretPhraseOrPrivateKey@3x.png b/src/assets/ImportSecretPhraseOrPrivateKey@3x.png new file mode 100644 index 00000000000..60ac917f099 Binary files /dev/null and b/src/assets/ImportSecretPhraseOrPrivateKey@3x.png differ diff --git a/src/assets/ManuallyBackedUp.png b/src/assets/ManuallyBackedUp.png new file mode 100644 index 00000000000..5345ded5449 Binary files /dev/null and b/src/assets/ManuallyBackedUp.png differ diff --git a/src/assets/ManuallyBackedUp@2x.png b/src/assets/ManuallyBackedUp@2x.png new file mode 100644 index 00000000000..c2a53439487 Binary files /dev/null and b/src/assets/ManuallyBackedUp@2x.png differ diff --git a/src/assets/ManuallyBackedUp@3x.png b/src/assets/ManuallyBackedUp@3x.png new file mode 100644 index 00000000000..6b34943018a Binary files /dev/null and b/src/assets/ManuallyBackedUp@3x.png differ diff --git a/src/assets/PairHardwareWallet.png b/src/assets/PairHardwareWallet.png new file mode 100644 index 00000000000..52110f7bcaf Binary files /dev/null and b/src/assets/PairHardwareWallet.png differ diff --git a/src/assets/PairHardwareWallet@2x.png b/src/assets/PairHardwareWallet@2x.png new file mode 100644 index 00000000000..57479c7e16e Binary files /dev/null and b/src/assets/PairHardwareWallet@2x.png differ diff --git a/src/assets/PairHardwareWallet@3x.png b/src/assets/PairHardwareWallet@3x.png new file mode 100644 index 00000000000..3aa3176162b Binary files /dev/null and b/src/assets/PairHardwareWallet@3x.png differ diff --git a/src/assets/WalletsAndBackup.png b/src/assets/WalletsAndBackup.png new file mode 100644 index 00000000000..ab941378faf Binary files /dev/null and b/src/assets/WalletsAndBackup.png differ diff --git a/src/assets/WalletsAndBackup@2x.png b/src/assets/WalletsAndBackup@2x.png new file mode 100644 index 00000000000..ddb2902f597 Binary files /dev/null and b/src/assets/WalletsAndBackup@2x.png differ diff --git a/src/assets/WalletsAndBackup@3x.png b/src/assets/WalletsAndBackup@3x.png new file mode 100644 index 00000000000..a0c0a72c63f Binary files /dev/null and b/src/assets/WalletsAndBackup@3x.png differ diff --git a/src/assets/watchWallet.png b/src/assets/watchWallet.png new file mode 100644 index 00000000000..11660b91e67 Binary files /dev/null and b/src/assets/watchWallet.png differ diff --git a/src/assets/watchWallet@2x.png b/src/assets/watchWallet@2x.png new file mode 100644 index 00000000000..f240e0340d6 Binary files /dev/null and b/src/assets/watchWallet@2x.png differ diff --git a/src/assets/watchWallet@3x.png b/src/assets/watchWallet@3x.png new file mode 100644 index 00000000000..1a0dba1b90f Binary files /dev/null and b/src/assets/watchWallet@3x.png differ diff --git a/src/components/DappBrowser/BrowserContext.tsx b/src/components/DappBrowser/BrowserContext.tsx index a62e1f20f06..73ed0be8068 100644 --- a/src/components/DappBrowser/BrowserContext.tsx +++ b/src/components/DappBrowser/BrowserContext.tsx @@ -1,43 +1,88 @@ /* eslint-disable @typescript-eslint/no-empty-function */ -import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; -import Animated, { Easing, runOnJS, useAnimatedRef, useScrollViewOffset, useSharedValue, withTiming } from 'react-native-reanimated'; -import WebView from 'react-native-webview'; -import isEqual from 'react-fast-compare'; +import React, { createContext, useCallback, useContext, useRef, useState } from 'react'; import { TextInput } from 'react-native'; +import isEqual from 'react-fast-compare'; +import { MMKV, useMMKVObject } from 'react-native-mmkv'; +import Animated, { + AnimatedRef, + SharedValue, + runOnJS, + runOnUI, + useAnimatedRef, + useScrollViewOffset, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import WebView from 'react-native-webview'; +import { SPRING_CONFIGS } from '@/components/animations/animationConfigs'; +import { generateUniqueId } from './utils'; + +interface BrowserTabViewProgressContextType { + tabViewProgress: SharedValue | undefined; +} + +const DEFAULT_PROGRESS_CONTEXT = { + tabViewProgress: undefined, +}; + +const BrowserTabViewProgressContext = createContext(DEFAULT_PROGRESS_CONTEXT); + +export const useBrowserTabViewProgressContext = () => useContext(BrowserTabViewProgressContext); + +export const BrowserTabViewProgressContextProvider = ({ children }: { children: React.ReactNode }) => { + const tabViewProgress = useSharedValue(0); + + return {children}; +}; interface BrowserContextType { activeTabIndex: number; - closeTab: (tabIndex: number) => void; + activeTabRef: React.MutableRefObject; + animatedActiveTabIndex: SharedValue | undefined; + closeTab: (tabId: string) => void; goBack: () => void; goForward: () => void; - isSearchInputFocused: boolean; + loadProgress: SharedValue | undefined; newTab: () => void; onRefresh: () => void; searchInputRef: React.RefObject; - searchViewProgress: Animated.SharedValue | undefined; - scrollViewOffset: Animated.SharedValue | undefined; - scrollViewRef: React.MutableRefObject; + searchViewProgress: SharedValue | undefined; + scrollViewOffset: SharedValue | undefined; + scrollViewRef: AnimatedRef; setActiveTabIndex: React.Dispatch>; - setIsSearchInputFocused: React.Dispatch>; tabStates: TabState[]; - tabViewProgress: Animated.SharedValue | undefined; - tabViewFullyVisible: boolean; - tabViewVisible: boolean; - toggleTabView: () => void; - updateActiveTabState: (tabIndex: number, newState: Partial) => void; - webViewRefs: React.MutableRefObject<(WebView | null)[]>; + tabViewProgress: SharedValue | undefined; + tabViewVisible: SharedValue | undefined; + toggleTabViewWorklet: (activeIndex?: number) => void; + updateActiveTabState: (newState: Partial, tabId?: string) => void; } -interface TabState { +export interface TabState { canGoBack: boolean; canGoForward: boolean; + uniqueId: string; url: string; + logoUrl?: string | null; } export const RAINBOW_HOME = 'RAINBOW_HOME'; -const defaultContext: BrowserContextType = { +const DEFAULT_TAB_STATE: TabState[] = [ + { canGoBack: false, canGoForward: false, uniqueId: generateUniqueId(), url: RAINBOW_HOME }, + { + canGoBack: false, + canGoForward: false, + uniqueId: generateUniqueId(), + url: 'https://bx-e2e-dapp.vercel.app', + }, + { canGoBack: false, canGoForward: false, uniqueId: generateUniqueId(), url: 'https://app.uniswap.org/swap' }, + { canGoBack: false, canGoForward: false, uniqueId: generateUniqueId(), url: 'https://meme.market' }, +]; + +const DEFAULT_BROWSER_CONTEXT: BrowserContextType = { activeTabIndex: 0, + activeTabRef: { current: null }, + animatedActiveTabIndex: undefined, closeTab: () => { return; }, @@ -47,182 +92,192 @@ const defaultContext: BrowserContextType = { goForward: () => { return; }, - isSearchInputFocused: false, newTab: () => { return; }, - tabViewProgress: undefined, onRefresh: () => { return; }, searchInputRef: { current: null }, searchViewProgress: undefined, scrollViewOffset: undefined, + // @ts-expect-error Explicitly allowing null/undefined on the AnimatedRef causes type issues scrollViewRef: { current: null }, setActiveTabIndex: () => { return; }, - setIsSearchInputFocused: () => { + tabStates: DEFAULT_TAB_STATE, + tabViewProgress: undefined, + tabViewVisible: undefined, + tabViewVisibleRef: { current: null }, + toggleTabView: () => { return; }, - tabStates: [ - { url: RAINBOW_HOME, canGoBack: false, canGoForward: false }, - { - url: 'https://bx-e2e-dapp.vercel.app/', - canGoBack: false, - canGoForward: false, - }, - { url: 'https://app.uniswap.org/', canGoBack: false, canGoForward: false }, - ], - tabViewFullyVisible: false, - tabViewVisible: false, - toggleTabView: () => { + toggleTabViewWorklet: () => { + 'worklet'; return; }, updateActiveTabState: () => { return; }, - webViewRefs: { current: [] }, }; -const BrowserContext = createContext(defaultContext); +const BrowserContext = createContext(DEFAULT_BROWSER_CONTEXT); export const useBrowserContext = () => useContext(BrowserContext); -const timingConfig = { - duration: 500, - easing: Easing.bezier(0.22, 1, 0.36, 1), -}; +const tabStateStore = new MMKV(); + +const EMPTY_TAB_STATE: TabState[] = []; -// this is sloppy and causes tons of rerenders, needs to be reworked export const BrowserContextProvider = ({ children }: { children: React.ReactNode }) => { const [activeTabIndex, setActiveTabIndex] = useState(0); - const [isSearchInputFocused, setIsSearchInputFocused] = useState(false); - const [tabStates, setTabStates] = useState(defaultContext.tabStates); - const [tabViewFullyVisible, setTabViewFullyVisible] = useState(false); - const [tabViewVisible, setTabViewVisible] = useState(false); + const [tabStates, setTabStates] = useMMKVObject('tabStateStorage', tabStateStore); const updateActiveTabState = useCallback( - (tabIndex: number, newState: Partial) => { + (newState: Partial, tabId?: string) => { + if (!tabStates) return; + + const tabIndex = tabId ? tabStates.findIndex(tab => tab.uniqueId === tabId) : activeTabIndex; + if (tabIndex === -1) return; + if (isEqual(tabStates[tabIndex], newState)) return; - setTabStates(prevTabStates => { - const updatedTabs = [...prevTabStates]; - updatedTabs[tabIndex] = { ...updatedTabs[tabIndex], ...newState }; - return updatedTabs; - }); + + const updatedTabs = [...tabStates]; + updatedTabs[tabIndex] = { ...updatedTabs[tabIndex], ...newState }; + + setTabStates(updatedTabs); }, - [tabStates] + [activeTabIndex, setTabStates, tabStates] ); const searchInputRef = useRef(null); const scrollViewRef = useAnimatedRef(); - const webViewRefs = useRef([]); + const activeTabRef = useRef(null); + const loadProgress = useSharedValue(0); const searchViewProgress = useSharedValue(0); const scrollViewOffset = useScrollViewOffset(scrollViewRef); - const tabViewProgress = useSharedValue(0); + const tabViewVisible = useSharedValue(false); + const animatedActiveTabIndex = useSharedValue(0); + const { tabViewProgress } = useBrowserTabViewProgressContext(); + + const toggleTabViewWorklet = useCallback( + (activeIndex?: number) => { + 'worklet'; + const willTabViewBecomeVisible = !tabViewVisible.value; + const tabIndexProvided = activeIndex !== undefined; + + if (tabIndexProvided && !willTabViewBecomeVisible) { + animatedActiveTabIndex.value = activeIndex; + runOnJS(setActiveTabIndex)(activeIndex); + } + if (tabViewProgress !== undefined) { + tabViewProgress.value = willTabViewBecomeVisible + ? withSpring(100, SPRING_CONFIGS.browserTabTransition) + : withSpring(0, SPRING_CONFIGS.browserTabTransition); + } + + tabViewVisible.value = willTabViewBecomeVisible; + }, + [animatedActiveTabIndex, tabViewProgress, tabViewVisible] + ); - useEffect(() => { - if (isSearchInputFocused) { - searchViewProgress.value = withTiming(1, timingConfig); + const newTab = useCallback(() => { + const newTabToAdd = { + canGoBack: false, + canGoForward: false, + uniqueId: generateUniqueId(), + url: RAINBOW_HOME, + }; + + if (!tabStates) { + setTabStates([newTabToAdd]); + runOnUI(toggleTabViewWorklet)(0); } else { - searchViewProgress.value = withTiming(0, timingConfig); - } - }, [searchViewProgress, isSearchInputFocused]); - - const toggleTabView = useCallback(() => { - const isVisible = !tabViewVisible; - tabViewProgress.value = isVisible - ? withTiming(1, timingConfig, isFinished => { - if (isFinished) { - runOnJS(setTabViewFullyVisible)(true); - } - }) - : withTiming(0, timingConfig); - - setTabViewVisible(isVisible); - if (!isVisible) { - setTabViewFullyVisible(false); + const updatedTabs = [...tabStates, newTabToAdd]; + setTabStates(updatedTabs); + runOnUI(toggleTabViewWorklet)(updatedTabs.length - 1); } - }, [tabViewProgress, tabViewVisible]); + }, [setTabStates, tabStates, toggleTabViewWorklet]); const closeTab = useCallback( - (tabIndex: number) => { - setTabStates(prevTabStates => { - const updatedTabs = [...prevTabStates]; - if (tabIndex === activeTabIndex) { - if (tabIndex < updatedTabs.length - 1) { - setActiveTabIndex(tabIndex); - } else if (tabIndex > 0) { - setActiveTabIndex(tabIndex - 1); - } + (tabId: string) => { + if (!tabStates) return; + + const tabIndex = tabStates.findIndex(tab => tab.uniqueId === tabId); + if (tabIndex === -1) return; + + const isActiveTab = tabIndex === activeTabIndex; + const isLastTab = tabIndex === tabStates.length - 1; + const hasNextTab = tabIndex < tabStates.length - 1; + + let newActiveTabIndex = activeTabIndex; + + if (isActiveTab) { + if (isLastTab && tabIndex === 0) { + setActiveTabIndex(0); + animatedActiveTabIndex.value = 0; + setTabStates(EMPTY_TAB_STATE); + newTab(); + return; + } else if (isLastTab && tabIndex > 0) { + newActiveTabIndex = tabIndex - 1; + } else if (hasNextTab) { + newActiveTabIndex = tabIndex; } - updatedTabs.splice(tabIndex, 1); - webViewRefs.current.splice(tabIndex, 1); - return updatedTabs; - }); + } else if (tabIndex < activeTabIndex) { + newActiveTabIndex = activeTabIndex - 1; + } + + const updatedTabs = [...tabStates.slice(0, tabIndex), ...tabStates.slice(tabIndex + 1)]; + setTabStates(updatedTabs); + setActiveTabIndex(newActiveTabIndex); + animatedActiveTabIndex.value = newActiveTabIndex; }, - [activeTabIndex, setActiveTabIndex, setTabStates, webViewRefs] + [activeTabIndex, animatedActiveTabIndex, newTab, setTabStates, tabStates] ); - const newTab = useCallback(() => { - setActiveTabIndex(tabStates.length); - setTabStates(prevTabStates => { - const updatedTabs = [...prevTabStates]; - updatedTabs.push({ - canGoBack: false, - canGoForward: false, - url: RAINBOW_HOME, - }); - return updatedTabs; - }); - toggleTabView(); - }, [setTabStates, tabStates.length, toggleTabView]); - const goBack = useCallback(() => { - const activeWebview = webViewRefs.current[activeTabIndex]; - if (activeWebview && tabStates[activeTabIndex].canGoBack) { - activeWebview.goBack(); + if (activeTabRef.current && tabStates?.[activeTabIndex]?.canGoBack) { + activeTabRef.current.goBack(); } - }, [activeTabIndex, tabStates, webViewRefs]); + }, [activeTabIndex, activeTabRef, tabStates]); const goForward = useCallback(() => { - const activeWebview = webViewRefs.current[activeTabIndex]; - if (activeWebview && tabStates[activeTabIndex].canGoForward) { - activeWebview.goForward(); + if (activeTabRef.current && tabStates?.[activeTabIndex]?.canGoForward) { + activeTabRef.current.goForward(); } - }, [activeTabIndex, tabStates, webViewRefs]); + }, [activeTabIndex, activeTabRef, tabStates]); const onRefresh = useCallback(() => { - const activeWebview = webViewRefs.current[activeTabIndex]; - if (activeWebview) { - activeWebview.reload(); + if (activeTabRef.current) { + activeTabRef.current.reload(); } - }, [activeTabIndex, webViewRefs]); + }, [activeTabRef]); return ( {children} diff --git a/src/components/DappBrowser/BrowserTab.tsx b/src/components/DappBrowser/BrowserTab.tsx index 517711cb3bb..ec2be197b08 100644 --- a/src/components/DappBrowser/BrowserTab.tsx +++ b/src/components/DappBrowser/BrowserTab.tsx @@ -1,268 +1,516 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Box, globalColors, useColorMode, TextIcon } from '@/design-system'; -import { useAccountAccentColor, useAccountSettings, useDimensions } from '@/hooks'; -import { AnimatePresence, MotiView } from 'moti'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import Animated, { Easing, interpolate, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import { FasterImageView, ImageOptions } from '@candlefinance/faster-image'; +import { Box, globalColors, useColorMode } from '@/design-system'; +import { useDimensions } from '@/hooks'; +import React, { useCallback, useLayoutEffect, useRef } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { + PanGestureHandler, + PanGestureHandlerGestureEvent, + TapGestureHandler, + TapGestureHandlerGestureEvent, +} from 'react-native-gesture-handler'; +import Animated, { + convertToRGBA, + dispatchCommand, + interpolate, + isColor, + runOnJS, + setNativeProps, + useAnimatedGestureHandler, + useAnimatedReaction, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated'; import ViewShot from 'react-native-view-shot'; import WebView, { WebViewMessageEvent, WebViewNavigation } from 'react-native-webview'; import { deviceUtils, safeAreaInsetValues } from '@/utils'; -import { transformOrigin } from 'react-native-redash'; import { MMKV } from 'react-native-mmkv'; -import { RAINBOW_HOME, useBrowserContext } from './BrowserContext'; -import { Image, StyleSheet, View, TouchableWithoutFeedback } from 'react-native'; +import { RAINBOW_HOME, TabState, useBrowserContext } from './BrowserContext'; import { Freeze } from 'react-freeze'; -import { COLLAPSED_WEBVIEW_HEIGHT_UNSCALED, TAB_VIEW_COLUMN_WIDTH, TAB_VIEW_ROW_HEIGHT, WEBVIEW_HEIGHT } from './Dimensions'; +import { + COLLAPSED_WEBVIEW_HEIGHT_UNSCALED, + INVERTED_MULTI_TAB_SCALE, + INVERTED_SINGLE_TAB_SCALE, + TAB_VIEW_COLUMN_WIDTH, + TAB_VIEW_ROW_HEIGHT, + TAB_VIEW_TAB_HEIGHT, + WEBVIEW_HEIGHT, +} from './Dimensions'; import RNFS from 'react-native-fs'; import { WebViewEvent } from 'react-native-webview/lib/WebViewTypes'; import { appMessenger } from '@/browserMessaging/AppMessenger'; +import { IS_ANDROID, IS_DEV, IS_IOS } from '@/env'; +import { CloseTabButton, X_BUTTON_PADDING, X_BUTTON_SIZE } from './CloseTabButton'; import DappBrowserWebview from './DappBrowserWebview'; -import { IS_ANDROID, IS_IOS } from '@/env'; -import { DappBrowserShadows } from './DappBrowserShadows'; -import { WebViewBorder } from './WebViewBorder'; import Homepage from './Homepage'; -import { ButtonPressAnimation } from '../animations'; import { handleProviderRequestApp } from './handleProviderRequest'; +import { WebViewBorder } from './WebViewBorder'; +import { SPRING_CONFIGS, TIMING_CONFIGS } from '../animations/animationConfigs'; +import { FASTER_IMAGE_CONFIG } from './constants'; +import { RainbowError, logger } from '@/logger'; +import { isEmpty } from 'lodash'; + +// ⚠️ TODO: Split this file apart into hooks, smaller components +// useTabScreenshots, useAnimatedWebViewStyles, useWebViewGestures interface BrowserTabProps { + tabId: string; tabIndex: number; injectedJS: string; } -type ScreenshotType = { - id: string; - isRendered?: boolean; - uri: string; -}; +interface ScreenshotType { + id: string; // <- the tab uniqueId + timestamp: number; // <- time of capture + uri: string; // <- screenshot file name = `screenshot-${timestamp}.jpg` + url: string; // <- url of the tab +} -const timingConfig = { - duration: 500, - easing: Easing.bezier(0.22, 1, 0.36, 1), -}; +const AnimatedFasterImage = Animated.createAnimatedComponent(FasterImageView); -const screenshotStorage = new MMKV(); +const tabScreenshotStorage = new MMKV(); const getStoredScreenshots = (): ScreenshotType[] => { - const persistedScreenshots = screenshotStorage.getString('screenshotTestStorage'); + const persistedScreenshots = tabScreenshotStorage.getString('tabScreenshots'); return persistedScreenshots ? (JSON.parse(persistedScreenshots) as ScreenshotType[]) : []; }; -// need a better key system for tabs and persisted data -const getTabId = (tabIndex: number, url: string) => { - return `${tabIndex}-${url}`; -}; - -const getInitialScreenshot = (id: string): ScreenshotType | null => { - const persistedData = screenshotStorage.getString('screenshotTestStorage'); +const findTabScreenshot = (id: string, url: string): ScreenshotType | null => { + const persistedData = tabScreenshotStorage.getString('tabScreenshots'); if (persistedData) { const screenshots = JSON.parse(persistedData); if (!Array.isArray(screenshots)) { - console.error('Retrieved screenshots data is not an array'); + try { + logger.error(new RainbowError('Screenshot data is malformed — expected array'), { + screenshots: JSON.stringify(screenshots, null, 2), + }); + } catch (e: any) { + logger.error(new RainbowError('Screenshot data is malformed — error stringifying'), { + message: e.message, + }); + } return null; } - const matchingScreenshot = screenshots.find(screenshot => screenshot.id === id); - if (matchingScreenshot) { + const matchingScreenshots = screenshots.filter(screenshot => screenshot.id === id); + const screenshotsWithMatchingUrl = matchingScreenshots.filter(screenshot => screenshot.url === url); + + if (screenshotsWithMatchingUrl.length > 0) { + const mostRecentScreenshot = screenshotsWithMatchingUrl.reduce((a, b) => (a.timestamp > b.timestamp ? a : b)); return { - ...matchingScreenshot, - uri: `${RNFS.DocumentDirectoryPath}/${matchingScreenshot.uri}`, + ...mostRecentScreenshot, + uri: `${RNFS.DocumentDirectoryPath}/${mostRecentScreenshot.uri}`, }; } } + return null; }; +export const pruneScreenshots = async (tabStates: TabState[]): Promise => { + const tabStateMap = tabStates.reduce((acc: Record, tab: TabState) => { + acc[tab.uniqueId] = tab.url; + return acc; + }, {}); + + const persistedData = tabScreenshotStorage.getString('tabScreenshots'); + if (!persistedData) return; + + const screenshots: ScreenshotType[] = JSON.parse(persistedData) || []; + const screenshotsGroupedByTabId: Record = screenshots.reduce( + (acc: Record, screenshot: ScreenshotType) => { + if (tabStateMap[screenshot.id]) { + if (!acc[screenshot.id]) acc[screenshot.id] = []; + acc[screenshot.id].push(screenshot); + } + return acc; + }, + {} + ); + + const screenshotsToKeep: ScreenshotType[] = Object.values(screenshotsGroupedByTabId) + .map((group: ScreenshotType[]) => { + return group.reduce((mostRecent: ScreenshotType, current: ScreenshotType) => { + return new Date(mostRecent.timestamp) > new Date(current.timestamp) ? mostRecent : current; + }); + }) + .filter((screenshot: ScreenshotType) => tabStateMap[screenshot.id] === screenshot.url); + + await deletePrunedScreenshotFiles(screenshots, screenshotsToKeep); + + tabScreenshotStorage.set('tabScreenshots', JSON.stringify(screenshotsToKeep)); +}; + +const deletePrunedScreenshotFiles = async (allScreenshots: ScreenshotType[], screenshotsToKeep: ScreenshotType[]): Promise => { + try { + const filesToDelete = allScreenshots.filter(screenshot => !screenshotsToKeep.includes(screenshot)); + const deletePromises = filesToDelete.map(screenshot => { + const filePath = `${RNFS.DocumentDirectoryPath}/${screenshot.uri}`; + return RNFS.unlink(filePath).catch(e => { + logger.error(new RainbowError('Error deleting screenshot file'), { + message: e.message, + filePath, + screenshot: JSON.stringify(screenshot, null, 2), + }); + }); + }); + await Promise.all(deletePromises); + } catch (e: any) { + logger.error(new RainbowError('Screenshot file pruning operation failed to complete'), { + message: e.message, + }); + } +}; + const getWebsiteBackgroundColorAndTitle = ` const bgColor = window.getComputedStyle(document.body, null).getPropertyValue('background-color'); + let appleTouchIconHref = document.querySelector("link[rel='apple-touch-icon']")?.getAttribute('href'); + if (appleTouchIconHref && !appleTouchIconHref.startsWith('http')) { + appleTouchIconHref = window.location.origin + appleTouchIconHref; + } window.ReactNativeWebView.postMessage(JSON.stringify({ topic: "bg", payload: bgColor})); window.ReactNativeWebView.postMessage(JSON.stringify({ topic: "title", payload: document.title })); + window.ReactNativeWebView.postMessage(JSON.stringify({ topic: "logo", payload: appleTouchIconHref })); true; `; -export const BrowserTab = React.memo(function BrowserTab({ tabIndex, injectedJS }: BrowserTabProps) { +export const BrowserTab = React.memo(function BrowserTab({ tabId, tabIndex, injectedJS }: BrowserTabProps) { const { activeTabIndex, + activeTabRef, + animatedActiveTabIndex, closeTab, + loadProgress, + scrollViewRef, scrollViewOffset, - setActiveTabIndex, tabStates, tabViewProgress, - tabViewFullyVisible, tabViewVisible, - toggleTabView, + toggleTabViewWorklet, updateActiveTabState, - webViewRefs, } = useBrowserContext(); - const { colorMode } = useColorMode(); - const { width: deviceWidth } = useDimensions(); - const { accentColor } = useAccountAccentColor(); const { isDarkMode } = useColorMode(); - const [title, setTitle] = useState(''); + const { width: deviceWidth } = useDimensions(); const currentMessenger = useRef(null); - + const title = useRef(null); + const logo = useRef(null); const webViewRef = useRef(null); const viewShotRef = useRef(null); - const isActiveTab = useMemo(() => activeTabIndex === tabIndex, [activeTabIndex, tabIndex]); + const panRef = useRef(); + const tapRef = useRef(); + + // ⚠️ TODO + const gestureScale = useSharedValue(1); + const gestureX = useSharedValue(0); + const gestureY = useSharedValue(0); + // 👆 Regarding these values 👆 + // Probably more efficient to swap these out for a combined SharedValue object, + // which can then be manipulated in the gesture handlers with the .modify() method. + // + // const closeTabGesture = useSharedValue({ + // gestureScale: 1, + // gestureX: 0, + // gestureY: 0, + // }); + + const tabUrl = tabStates?.[tabIndex]?.url; + const isActiveTab = activeTabIndex === tabIndex; + const multipleTabsOpen = tabStates?.length > 1; + const isOnHomepage = tabUrl === RAINBOW_HOME; + const isEmptyState = !multipleTabsOpen && isOnHomepage; + const isLogoUnset = tabStates[tabIndex]?.logoUrl === undefined; + + const screenshotData = useSharedValue(findTabScreenshot(tabId, tabUrl) || undefined); + + const defaultBackgroundColor = isDarkMode ? '#191A1C' : globalColors.white100; + const backgroundColor = useSharedValue(defaultBackgroundColor); + + const animatedWebViewBackgroundColorStyle = useAnimatedStyle(() => { + const homepageColor = isDarkMode ? globalColors.grey100 : '#FBFCFD'; + + if (isOnHomepage) return { backgroundColor: homepageColor }; + if (!backgroundColor.value) return { backgroundColor: defaultBackgroundColor }; + + if (isColor(backgroundColor.value)) { + const rgbaColor = convertToRGBA(backgroundColor.value); + + if (rgbaColor[3] < 1) { + return { backgroundColor: `rgba(${rgbaColor[0]}, ${rgbaColor[1]}, ${rgbaColor[2]}, 1)` }; + } else { + return { backgroundColor: backgroundColor.value }; + } + } else { + return { backgroundColor: defaultBackgroundColor }; + } + }); + + const animatedWebViewHeight = useDerivedValue(() => { + // For some reason driving the WebView height with a separate derived + // value results in slightly less tearing when the height animates + const animatedIsActiveTab = animatedActiveTabIndex?.value === tabIndex; + if (!animatedIsActiveTab) return COLLAPSED_WEBVIEW_HEIGHT_UNSCALED; - const tabId = useMemo(() => `${tabIndex}-${tabStates[tabIndex].url}`, [tabIndex, tabStates]); + const progress = tabViewProgress?.value || 0; - const webViewStyle = useAnimatedStyle(() => { - const isActiveTab = activeTabIndex === tabIndex; - const multipleTabsOpen = tabStates.length > 1; + return interpolate( + progress, + [0, 100], + [animatedIsActiveTab ? WEBVIEW_HEIGHT : COLLAPSED_WEBVIEW_HEIGHT_UNSCALED, COLLAPSED_WEBVIEW_HEIGHT_UNSCALED], + 'clamp' + ); + }); - const progress = tabViewProgress?.value ?? 0; + const animatedWebViewStyle = useAnimatedStyle(() => { + const progress = tabViewProgress?.value || 0; + const animatedIsActiveTab = animatedActiveTabIndex?.value === tabIndex; - const xPositionStart = isActiveTab ? 0 : (tabIndex % 2) * (TAB_VIEW_COLUMN_WIDTH + 20) - (TAB_VIEW_COLUMN_WIDTH + 20) / 2; + const scale = interpolate( + progress, + [0, 100], + [animatedIsActiveTab ? 1 : TAB_VIEW_COLUMN_WIDTH / deviceWidth, multipleTabsOpen ? TAB_VIEW_COLUMN_WIDTH / deviceWidth : 0.7] + ); - // eslint-disable-next-line no-nested-ternary + const xPositionStart = animatedIsActiveTab ? 0 : (tabIndex % 2) * (TAB_VIEW_COLUMN_WIDTH + 20) - (TAB_VIEW_COLUMN_WIDTH + 20) / 2; const xPositionEnd = multipleTabsOpen ? (tabIndex % 2) * (TAB_VIEW_COLUMN_WIDTH + 20) - (TAB_VIEW_COLUMN_WIDTH + 20) / 2 : 0; + const xPositionForTab = interpolate(progress, [0, 100], [xPositionStart, xPositionEnd]); - const xPositionForTab = interpolate(progress, [0, 1], [xPositionStart, xPositionEnd]); + const extraYPadding = 20; const yPositionStart = - (isActiveTab ? 0 : Math.floor(tabIndex / 2) * TAB_VIEW_ROW_HEIGHT - TAB_VIEW_ROW_HEIGHT / 2 - 12) + - (isActiveTab ? (1 - (tabViewProgress?.value ?? 1)) * (scrollViewOffset?.value ?? 0) : 0); - + (animatedIsActiveTab ? 0 : Math.floor(tabIndex / 2) * TAB_VIEW_ROW_HEIGHT + extraYPadding) + + (animatedIsActiveTab ? (1 - progress / 100) * (scrollViewOffset?.value || 0) : 0); const yPositionEnd = - (multipleTabsOpen ? Math.floor(tabIndex / 2) * TAB_VIEW_ROW_HEIGHT - TAB_VIEW_ROW_HEIGHT / 2 - 12 : 0) + - (isActiveTab ? (1 - (tabViewProgress?.value ?? 1)) * (scrollViewOffset?.value ?? 0) : 0); - - const yPositionForTab = interpolate(progress, [0, 1], [yPositionStart, yPositionEnd]); - - const scaleValue = interpolate( + (multipleTabsOpen ? Math.floor(tabIndex / 2) * TAB_VIEW_ROW_HEIGHT + extraYPadding : 0) + + (animatedIsActiveTab ? (1 - progress / 100) * (scrollViewOffset?.value || 0) : 0); + const yPositionForTab = interpolate(progress, [0, 100], [yPositionStart, yPositionEnd]); + + // Determine the border radius for the minimized tab that + // achieves concentric corners around the close button + const invertedScale = multipleTabsOpen ? INVERTED_MULTI_TAB_SCALE : INVERTED_SINGLE_TAB_SCALE; + const spaceToXButton = invertedScale * X_BUTTON_PADDING; + const xButtonBorderRadius = (X_BUTTON_SIZE / 2) * invertedScale; + const tabViewBorderRadius = xButtonBorderRadius + spaceToXButton; + + const borderRadius = interpolate( progress, - [0, 1], - [isActiveTab ? 1 : TAB_VIEW_COLUMN_WIDTH / deviceWidth, multipleTabsOpen ? TAB_VIEW_COLUMN_WIDTH / deviceWidth : 0.7] - ); - - // eslint-disable-next-line no-nested-ternary - const borderRadius = interpolate(progress, [0, 1], [isActiveTab ? (IS_ANDROID ? 0 : 16) : 30, 30]); - const height = interpolate( - progress, - [0, 1], - [isActiveTab ? WEBVIEW_HEIGHT : COLLAPSED_WEBVIEW_HEIGHT_UNSCALED, COLLAPSED_WEBVIEW_HEIGHT_UNSCALED] + [0, 100], + // eslint-disable-next-line no-nested-ternary + [animatedIsActiveTab ? (IS_ANDROID ? 0 : 16) : tabViewBorderRadius, tabViewBorderRadius], + 'clamp' ); - const opacity = interpolate(progress, [0, 1], [isActiveTab ? 1 : 0, 1]); - const transformWithOrigin = transformOrigin( - { x: 0, y: -height / 2 }, // setting origin to top center - [{ translateX: xPositionForTab }, { translateY: yPositionForTab + (isActiveTab ? progress : 1) * 137 }, { scale: scaleValue }] - ); + const opacity = interpolate(progress, [0, 100], [animatedIsActiveTab ? 1 : 0, 1], 'clamp'); return { borderRadius, - height, + height: animatedWebViewHeight.value, opacity, // eslint-disable-next-line no-nested-ternary - pointerEvents: progress ? 'box-only' : isActiveTab ? 'auto' : 'none', - transform: transformWithOrigin, - zIndex: isActiveTab ? 9999 : 1, + pointerEvents: tabViewVisible?.value ? 'box-only' : animatedIsActiveTab ? 'auto' : 'none', + transform: [ + { translateY: multipleTabsOpen ? -animatedWebViewHeight.value / 2 : 0 }, + { translateX: xPositionForTab + gestureX.value }, + { translateY: yPositionForTab + gestureY.value }, + { scale: scale * gestureScale.value }, + { translateY: multipleTabsOpen ? animatedWebViewHeight.value / 2 : 0 }, + ], }; - }, [activeTabIndex, tabIndex, tabViewProgress]); + }); - const handlePress = () => { - if (tabViewVisible) { - if (isActiveTab) { - toggleTabView(); - } else { - setActiveTabIndex(tabIndex); - } - } - }; + const zIndexAnimatedStyle = useAnimatedStyle(() => { + const progress = tabViewProgress?.value || 0; + const animatedIsActiveTab = animatedActiveTabIndex?.value === tabIndex; + + const scaleWeighting = + gestureScale.value * + interpolate( + progress, + [0, 100], + [animatedIsActiveTab ? 1 : TAB_VIEW_COLUMN_WIDTH / deviceWidth, multipleTabsOpen ? TAB_VIEW_COLUMN_WIDTH / deviceWidth : 0.7], + 'clamp' + ); + const zIndex = scaleWeighting * (animatedIsActiveTab || gestureScale.value > 1 ? 9999 : 1); + + return { zIndex }; + }); const handleNavigationStateChange = useCallback( (navState: WebViewNavigation) => { - if (navState.url !== tabStates[tabIndex].url && navState.navigationType !== 'other') { - updateActiveTabState(tabIndex, { - canGoBack: navState.canGoBack, - canGoForward: navState.canGoForward, - url: navState.url, - }); + // Set the logo if it's not already set for the current website + // ⚠️ TODO: Modify this to check against the root domain or subdomain+domain + if ((isLogoUnset && !isEmpty(logo.current)) || navState.url !== tabStates[tabIndex].url) { + updateActiveTabState( + { + logoUrl: logo.current, + }, + tabId + ); + } + + // To prevent infinite redirect loops, we only update the URL if both of these are true: + // + // 1) the WebView's URL is different from the tabStates URL + // 2) the navigationType !== 'other', which gets triggered repeatedly in certain cases programatically + // + // This has the consequence of the tabStates page URL not always being updated when navigating within + // single-page apps, which we'll need to figure out a solution for, but it's an okay workaround for now. + // + // It has the benefit though of allowing navigation within single-page apps without triggering reloads + // due to the WebView's URL being altered (which happens when its source prop is updated). + // + // Additionally, the canGoBack/canGoForward states can become out of sync with the actual WebView state + // if they aren't set according to the logic below. There's likely a cleaner way to structure it, but + // this avoids setting back/forward states under the wrong conditions or more than once per event. + // + // To observe what's actually going on, you can import the navigationStateLogger helper and add it here. + + if (navState.url !== tabStates[tabIndex].url) { + if (navState.navigationType !== 'other') { + // If the URL DID ✅ change and navigationType !== 'other', we update the full tab state + updateActiveTabState( + { + canGoBack: navState.canGoBack, + canGoForward: navState.canGoForward, + url: navState.url, + }, + tabId + ); + } else { + // If the URL DID ✅ change and navigationType === 'other', we update only canGoBack and canGoForward + updateActiveTabState( + { + canGoBack: navState.canGoBack, + canGoForward: navState.canGoForward, + }, + tabId + ); + } } else { - updateActiveTabState(tabIndex, { - canGoBack: navState.canGoBack, - canGoForward: navState.canGoForward, - url: tabStates[tabIndex].url, - }); + // If the URL DID NOT ❌ change, we update only canGoBack and canGoForward + // This handles WebView reloads and cases where the WebView navigation state legitimately resets + updateActiveTabState( + { + canGoBack: navState.canGoBack, + canGoForward: navState.canGoForward, + }, + tabId + ); } }, - [tabIndex, tabStates, updateActiveTabState] + [isLogoUnset, tabId, logo, tabIndex, tabStates, updateActiveTabState] ); - useEffect(() => { - if (tabViewVisible) { - toggleTabView(); - webViewRef.current?.getSnapshotBeforeUpdate; + // useLayoutEffect seems to more reliably assign the ref correctly + useLayoutEffect(() => { + if (webViewRef.current !== null && isActiveTab) { + activeTabRef.current = webViewRef.current; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTabIndex]); + }, [isActiveTab, isOnHomepage, tabId]); - useEffect(() => { - if (webViewRef.current !== null) { - webViewRefs.current[tabIndex] = webViewRef.current; - } - - const currentWebviewRef = webViewRefs.current; - - return () => { - currentWebviewRef[tabIndex] = null; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tabIndex, webViewRef.current, webViewRefs]); - - const [screenshot, setScreenshot] = useState(() => getInitialScreenshot(tabId)); - - const saveScreenshotToFileSystem = async (tempUri: string, url: string) => { - const fileName = `screenshot-${Date.now()}.jpg`; - // const screenshotId = Date.now().toString(); - - try { - await RNFS.copyFile(tempUri, `${RNFS.DocumentDirectoryPath}/${fileName}`); - const newScreenshot: ScreenshotType = { - id: getTabId(tabIndex, url), - uri: fileName, - isRendered: true, - }; - - // Retrieve existing screenshots - const existingScreenshots = getStoredScreenshots(); - const updatedScreenshots = [...existingScreenshots, newScreenshot]; + const saveScreenshotToFileSystem = useCallback( + async (tempUri: string, tabId: string, timestamp: number, url: string) => { + const fileName = `screenshot-${timestamp}.jpg`; + try { + await RNFS.copyFile(tempUri, `${RNFS.DocumentDirectoryPath}/${fileName}`); + // Once the file is copied, build the screenshot object + const newScreenshot: ScreenshotType = { + id: tabId, + timestamp, + uri: fileName, + url, + }; + + // Retrieve existing screenshots and merge in the new one + const existingScreenshots = getStoredScreenshots(); + const updatedScreenshots = [...existingScreenshots, newScreenshot]; + + // Update MMKV store with the new screenshot + tabScreenshotStorage.set('tabScreenshots', JSON.stringify(updatedScreenshots)); + + // Determine current RNFS document directory + const screenshotWithRNFSPath: ScreenshotType = { + ...newScreenshot, + uri: `${RNFS.DocumentDirectoryPath}/${newScreenshot.uri}`, + }; + + // Set screenshot for display + screenshotData.value = screenshotWithRNFSPath; + } catch (e: any) { + logger.error(new RainbowError('Error saving tab screenshot to file system'), { + message: e.message, + screenshotData: { + tempUri, + tabId, + url, + }, + }); + } + }, + [screenshotData] + ); - screenshotStorage.set('screenshotTestStorage', JSON.stringify(updatedScreenshots)); - } catch (error) { - console.error('Error saving screenshot to file system:', error); - } - }; - - useEffect(() => { - if ( - tabViewFullyVisible && - isActiveTab && - viewShotRef.current && - webViewRefs.current[tabIndex] && - (!screenshot || screenshot.id !== tabId) - ) { + const captureAndSaveScreenshot = useCallback(() => { + if (viewShotRef.current && webViewRef.current) { const captureRef = viewShotRef.current; - if (captureRef && captureRef.capture) { + + if (captureRef && captureRef?.capture) { captureRef .capture() .then(uri => { - const url = tabStates[tabIndex].url; - setScreenshot({ id: tabId, uri }); - saveScreenshotToFileSystem(uri, url); + const timestamp = Date.now(); + saveScreenshotToFileSystem(uri, tabId, timestamp, tabStates[tabIndex].url); }) .catch(error => { - console.error('Failed to capture screenshot:', error); + logger.error(new RainbowError('Failed to capture tab screenshot'), { + error: error.message, + }); }); } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tabViewFullyVisible]); + }, [saveScreenshotToFileSystem, tabId, tabIndex, tabStates, viewShotRef]); + + const screenshotSource = useDerivedValue(() => { + return { + ...FASTER_IMAGE_CONFIG, + url: screenshotData.value?.uri ? `file://${screenshotData.value?.uri}` : '', + } as ImageOptions; + }); + + const animatedScreenshotStyle = useAnimatedStyle(() => { + const animatedIsActiveTab = animatedActiveTabIndex?.value === tabIndex; + const screenshotExists = !!screenshotData.value?.uri; + const screenshotMatchesTabIdAndUrl = screenshotData.value?.id === tabId && screenshotData.value?.url === tabStates[tabIndex].url; + + // This is to handle the case where a WebView that wasn't previously the active tab + // is made active from the tab view. Because its freeze state is driven by JS state, + // it doesn't unfreeze immediately, so this condition allows some time for the tab to + // become unfrozen before the screenshot is hidden, in most cases hiding the flash of + // the frozen empty WebView that occurs if the screenshot is hidden immediately. + const isActiveTabButMaybeStillFrozen = animatedIsActiveTab && (tabViewProgress?.value || 0) > 50 && !tabViewVisible?.value; + + const oneMinuteAgo = Date.now() - 1000 * 60; + const isScreenshotStale = screenshotData.value && screenshotData.value?.timestamp < oneMinuteAgo; + const shouldWaitForNewScreenshot = + isScreenshotStale && animatedIsActiveTab && !!tabViewVisible?.value && !isActiveTabButMaybeStillFrozen; + + const shouldDisplay = + screenshotExists && + screenshotMatchesTabIdAndUrl && + (!animatedIsActiveTab || !!tabViewVisible?.value || isActiveTabButMaybeStillFrozen) && + !shouldWaitForNewScreenshot; - const [backgroundColor, setBackgroundColor] = useState(); + return { + opacity: withSpring(shouldDisplay ? 1 : 0, SPRING_CONFIGS.snappierSpringConfig), + }; + }); const handleOnMessage = useCallback( (event: WebViewMessageEvent) => { @@ -273,9 +521,13 @@ export const BrowserTab = React.memo(function BrowserTab({ tabIndex, injectedJS const parsedData = typeof data === 'string' ? JSON.parse(data) : data; if (!parsedData || (!parsedData.topic && !parsedData.payload)) return; if (parsedData.topic === 'bg') { - setBackgroundColor(parsedData.payload); + if (typeof parsedData.payload === 'string') { + backgroundColor.value = parsedData.payload; + } } else if (parsedData.topic === 'title') { - setTitle(parsedData.payload); + title.current = parsedData.payload; + } else if (parsedData.topic === 'logo') { + logo.current = parsedData.payload; } else { const m = currentMessenger.current; handleProviderRequestApp({ @@ -286,7 +538,7 @@ export const BrowserTab = React.memo(function BrowserTab({ tabIndex, injectedJS sender: { url: m.url, tab: { id: tabId }, - title: title || tabStates[tabIndex].url, + title: title.current || tabStates[tabIndex].url, }, id: parsedData.id, }, @@ -298,7 +550,7 @@ export const BrowserTab = React.memo(function BrowserTab({ tabIndex, injectedJS console.error('Error parsing message', e); } }, - [isActiveTab, tabId, tabIndex, tabStates, title] + [isActiveTab, tabId, tabIndex, tabStates, title, backgroundColor] ); const handleOnLoadStart = useCallback( @@ -332,161 +584,200 @@ export const BrowserTab = React.memo(function BrowserTab({ tabIndex, injectedJS return true; }, []); - const loadProgress = useSharedValue(0); - - const progressBarStyle = useAnimatedStyle( - () => ({ - opacity: loadProgress.value === 1 ? withTiming(0, timingConfig) : withTiming(1, timingConfig), - width: loadProgress.value * deviceWidth, - }), - [] - ); - const handleOnLoadProgress = useCallback( ({ nativeEvent: { progress } }: { nativeEvent: { progress: number } }) => { if (loadProgress) { if (loadProgress.value === 1) loadProgress.value = 0; - loadProgress.value = withTiming(progress, timingConfig); + loadProgress.value = withTiming(progress, TIMING_CONFIGS.slowestFadeConfig); } }, [loadProgress] ); - const WebviewComponent = () => ( - + const swipeToCloseTabGestureHandler = useAnimatedGestureHandler({ + onStart: (_, ctx: { startX?: number }) => { + if (!tabViewVisible?.value) return; + if (ctx.startX) { + ctx.startX = undefined; + } + }, + onActive: (e, ctx: { startX?: number }) => { + if (!tabViewVisible?.value) return; + + if (ctx.startX === undefined) { + gestureScale.value = withTiming(1.1, TIMING_CONFIGS.tabPressConfig); + gestureY.value = withTiming(-0.05 * (multipleTabsOpen ? TAB_VIEW_TAB_HEIGHT : 0), TIMING_CONFIGS.tabPressConfig); + ctx.startX = e.absoluteX; } - > - - - ); - const isOnHomepage = tabStates[tabIndex].url === RAINBOW_HOME; - const TabContent = isOnHomepage ? : ; + setNativeProps(scrollViewRef, { scrollEnabled: false }); + dispatchCommand(scrollViewRef, 'scrollTo', [0, scrollViewOffset?.value, true]); + + const xDelta = e.absoluteX - ctx.startX; + gestureX.value = xDelta; + }, + onEnd: (e, ctx: { startX?: number }) => { + const xDelta = e.absoluteX - (ctx.startX || 0); + setNativeProps(scrollViewRef, { scrollEnabled: !!tabViewVisible?.value }); + + const isBeyondDismissThreshold = xDelta < -(TAB_VIEW_COLUMN_WIDTH / 2 + 20) && e.velocityX <= 0; + const isFastLeftwardSwipe = e.velocityX < -500; + + const shouldDismiss = !!tabViewVisible?.value && !isEmptyState && (isBeyondDismissThreshold || isFastLeftwardSwipe); + + if (shouldDismiss) { + const xDestination = -Math.min(Math.max(deviceWidth * 1.25, Math.abs(e.velocityX * 0.3)), 1000); + gestureX.value = withTiming(xDestination, TIMING_CONFIGS.tabPressConfig, () => { + runOnJS(closeTab)(tabId); + gestureScale.value = 0; + gestureX.value = 0; + gestureY.value = 0; + ctx.startX = undefined; + }); + } else { + gestureScale.value = withTiming(1, TIMING_CONFIGS.tabPressConfig); + gestureX.value = withTiming(0, TIMING_CONFIGS.tabPressConfig); + gestureY.value = withTiming(0, TIMING_CONFIGS.tabPressConfig); + ctx.startX = undefined; + } + }, + }); + + const pressTabGestureHandler = useAnimatedGestureHandler({ + onActive: () => { + if (tabViewVisible?.value) { + toggleTabViewWorklet(tabIndex); + } + }, + }); + + useAnimatedReaction( + () => tabViewProgress?.value, + (current, previous) => { + // Monitor changes in tabViewProgress and trigger tab screenshot capture if necessary + const changesDetected = previous && current !== previous; + const isActiveTab = animatedActiveTabIndex?.value === tabIndex; + + if (isActiveTab && changesDetected && !isOnHomepage) { + const enterTabViewAnimationIsComplete = tabViewVisible?.value === true && (previous || 0) > 100 && (current || 0) <= 100; + const isPageLoaded = (loadProgress?.value || 0) > 0.2; + + if (!enterTabViewAnimationIsComplete || !isPageLoaded) return; + + const previousScreenshotExists = !!screenshotData.value?.uri; + const tabIdChanged = screenshotData.value?.id !== tabId; + const urlChanged = screenshotData.value?.url !== tabUrl; + const oneMinuteAgo = Date.now() - 1000 * 60; + const isScreenshotStale = screenshotData.value && screenshotData.value?.timestamp < oneMinuteAgo; + + const shouldCaptureScreenshot = !previousScreenshotExists || tabIdChanged || urlChanged || isScreenshotStale; + + if (shouldCaptureScreenshot) { + runOnJS(captureAndSaveScreenshot)(); + } + } + } + ); return ( <> - - - - - - {TabContent} - - - - {(!isActiveTab || tabViewVisible) && ( - - console.error('Image loading error:', e.nativeEvent.error)} - source={{ uri: screenshot?.uri }} - style={[ - styles.webViewStyle, - { - left: 0, - position: 'absolute', - resizeMode: 'contain', - top: 0, - }, - ]} - width={deviceWidth} - /> - - )} - - - - - {tabViewVisible && tabStates.length > 1 && ( - closeTab(tabIndex)} - position="absolute" - top={{ custom: safeAreaInsetValues.top + Math.floor(tabIndex / 2) * TAB_VIEW_ROW_HEIGHT + 8 }} - left={{ custom: ((tabIndex % 2) + 1) * TAB_VIEW_COLUMN_WIDTH + (tabIndex % 2) * 20 - 8 }} - style={{ zIndex: 99999999999 }} + {/* Need to fix some shadow performance issues - disabling shadows for now */} + {/* */} + + {/* @ts-expect-error Property 'children' does not exist on type */} + + + {/* @ts-expect-error Property 'children' does not exist on type */} + - - 􀀳 - - - )} - - {isActiveTab && !tabViewVisible && ( - - )} + + + + {isOnHomepage ? ( + + ) : ( + + ( + + )} + onLoadEnd={handleOnLoadEnd} + onError={handleOnError} + onShouldStartLoadWithRequest={handleShouldStartLoadWithRequest} + onLoadProgress={handleOnLoadProgress} + onMessage={handleOnMessage} + onNavigationStateChange={handleNavigationStateChange} + ref={webViewRef} + source={{ uri: tabUrl || RAINBOW_HOME }} + style={[styles.webViewStyle, styles.transparentBackground]} + /> + + )} + + + + + closeTab(tabId)} tabIndex={tabIndex} /> + + + + + + {/* Need to fix some shadow performance issues - disabling shadows for now */} + {/* */} ); }); const styles = StyleSheet.create({ + backupScreenshotStyleOverrides: { + zIndex: -1, + }, + centerAlign: { + alignItems: 'center', + justifyContent: 'center', + }, + screenshotContainerStyle: { + height: WEBVIEW_HEIGHT, + left: 0, + position: 'absolute', + resizeMode: 'contain', + top: 0, + width: deviceUtils.dimensions.width, + zIndex: 20000, + }, + transparentBackground: { + backgroundColor: 'transparent', + }, webViewContainer: { alignSelf: 'center', + height: WEBVIEW_HEIGHT, overflow: 'hidden', position: 'absolute', top: safeAreaInsetValues.top, @@ -500,13 +791,37 @@ const styles = StyleSheet.create({ minHeight: WEBVIEW_HEIGHT, width: deviceUtils.dimensions.width, }, - progressBar: { - borderRadius: 1, - height: 2, - top: WEBVIEW_HEIGHT + safeAreaInsetValues.top + 88 - 2, - left: 0, - width: deviceUtils.dimensions.width, - position: 'absolute', - zIndex: 11000, - }, + // Need to fix some shadow performance issues - disabling shadows for now + // webViewContainerShadowLarge: IS_IOS + // ? { + // shadowColor: globalColors.grey100, + // shadowOffset: { width: 0, height: 8 }, + // shadowOpacity: 0.1, + // shadowRadius: 12, + // } + // : {}, + // webViewContainerShadowLargeDark: IS_IOS + // ? { + // shadowColor: globalColors.grey100, + // shadowOffset: { width: 0, height: 8 }, + // shadowOpacity: 0.3, + // shadowRadius: 12, + // } + // : {}, + // webViewContainerShadowSmall: IS_IOS + // ? { + // shadowColor: globalColors.grey100, + // shadowOffset: { width: 0, height: 2 }, + // shadowOpacity: 0.04, + // shadowRadius: 3, + // } + // : {}, + // webViewContainerShadowSmallDark: IS_IOS + // ? { + // shadowColor: globalColors.grey100, + // shadowOffset: { width: 0, height: 2 }, + // shadowOpacity: 0.2, + // shadowRadius: 3, + // } + // : {}, }); diff --git a/src/components/DappBrowser/BrowserToolbar.tsx b/src/components/DappBrowser/BrowserToolbar.tsx deleted file mode 100644 index 6bdf64c779e..00000000000 --- a/src/components/DappBrowser/BrowserToolbar.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { Bleed, Box, Columns, IconContainer, Inline, Text, TextIcon, useForegroundColor } from '@/design-system'; -import { TextColor } from '@/design-system/color/palettes'; -import { TextWeight } from '@/design-system/components/Text/Text'; -import { TextSize } from '@/design-system/typography/typeHierarchy'; -import { deviceUtils, safeAreaInsetValues } from '@/utils'; -import { useNavigation } from '@react-navigation/native'; -import React from 'react'; -import { Share, TouchableOpacity } from 'react-native'; -import Animated, { useAnimatedStyle } from 'react-native-reanimated'; -import { useBrowserContext } from './BrowserContext'; -import { useTheme } from '@/theme'; -import { ButtonPressAnimation } from '../animations'; - -export const ToolbarTextButton = ({ - color, - disabled, - label, - onPress, - showBackground, - textAlign, -}: { - color?: TextColor; - disabled?: boolean; - label: string; - onPress: () => void; - showBackground?: boolean; - textAlign?: 'center' | 'left' | 'right'; -}) => { - const { colors } = useTheme(); - const hexColor = useForegroundColor(color || 'blue'); - - return ( - - - - - {label} - - - - - ); -}; - -export const ToolbarIcon = ({ - color, - disabled, - hitSlop = 8, - icon, - onPress, - scaleTo, - size = 'icon 17px', - weight = 'bold', -}: { - color?: TextColor; - disabled?: boolean; - hitSlop?: number; - icon: string; - onPress?: () => void; - scaleTo?: number; - size?: TextSize; - weight?: TextWeight; -}) => { - return ( - - - {icon} - - - ); -}; - -export const BrowserToolbar = () => { - const { activeTabIndex, closeTab, goBack, goForward, newTab, tabStates, tabViewProgress, tabViewVisible, toggleTabView } = - useBrowserContext(); - const { goBack: closeBrowser } = useNavigation(); - const { canGoBack, canGoForward } = tabStates[activeTabIndex]; - - const barStyle = useAnimatedStyle(() => ({ - opacity: 1 - (tabViewProgress?.value ?? 0), - pointerEvents: tabViewVisible ? 'none' : 'auto', - })); - - const tabViewBarStyle = useAnimatedStyle(() => ({ - opacity: tabViewProgress?.value ?? 0, - pointerEvents: tabViewVisible ? 'auto' : 'none', - })); - - const onShare = async () => { - try { - await Share.share({ message: tabStates[activeTabIndex].url }); - } catch (error) { - console.error('Error sharing browser URL', error); - } - }; - - return ( - <> - - - - - {/* */} - - - - - - - - {/* */} - - - closeTab(tabStates.length - 1)} /> - - - - - - - ); -}; diff --git a/src/components/DappBrowser/CloseTabButton.tsx b/src/components/DappBrowser/CloseTabButton.tsx new file mode 100644 index 00000000000..2361b6cd5a9 --- /dev/null +++ b/src/components/DappBrowser/CloseTabButton.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { StyleSheet, TouchableOpacity } from 'react-native'; +import Animated, { interpolate, useAnimatedStyle, withTiming } from 'react-native-reanimated'; +import { Box, Cover, TextIcon, useColorMode } from '@/design-system'; +import { IS_IOS } from '@/env'; +import { deviceUtils } from '@/utils'; +import { AnimatedBlurView } from '@/__swaps__/screens/Swap/components/AnimatedBlurView'; +import { RAINBOW_HOME, useBrowserContext } from './BrowserContext'; +import { TAB_VIEW_COLUMN_WIDTH } from './Dimensions'; +import { TIMING_CONFIGS } from '../animations/animationConfigs'; + +// ⚠️ TODO: Fix close button press detection — currently being blocked +// by the gesture handlers within the BrowserTab component. + +export const X_BUTTON_SIZE = 22; +export const X_BUTTON_PADDING = 6; + +const INVERTED_WEBVIEW_SCALE = deviceUtils.dimensions.width / TAB_VIEW_COLUMN_WIDTH; +const SINGLE_TAB_INVERTED_WEBVIEW_SCALE = 10 / 7; + +const SCALE_ADJUSTED_X_BUTTON_SIZE = X_BUTTON_SIZE * INVERTED_WEBVIEW_SCALE; +const SCALE_ADJUSTED_X_BUTTON_PADDING = X_BUTTON_PADDING * INVERTED_WEBVIEW_SCALE; + +const SCALE_ADJUSTED_X_BUTTON_SIZE_SINGLE_TAB = X_BUTTON_SIZE * SINGLE_TAB_INVERTED_WEBVIEW_SCALE; +const SCALE_ADJUSTED_X_BUTTON_PADDING_SINGLE_TAB = X_BUTTON_PADDING * SINGLE_TAB_INVERTED_WEBVIEW_SCALE; + +export const CloseTabButton = ({ onPress, tabIndex }: { onPress: () => void; tabIndex: number }) => { + const { animatedActiveTabIndex, tabStates, tabViewProgress, tabViewVisible } = useBrowserContext(); + const { isDarkMode } = useColorMode(); + + const multipleTabsOpen = tabStates.length > 1; + const tabUrl = tabStates[tabIndex]?.url; + const isOnHomepage = tabUrl === RAINBOW_HOME; + const isEmptyState = !multipleTabsOpen && isOnHomepage; + const buttonSize = multipleTabsOpen ? SCALE_ADJUSTED_X_BUTTON_SIZE : SCALE_ADJUSTED_X_BUTTON_SIZE_SINGLE_TAB; + const buttonPadding = multipleTabsOpen ? SCALE_ADJUSTED_X_BUTTON_PADDING : SCALE_ADJUSTED_X_BUTTON_PADDING_SINGLE_TAB; + + const closeButtonStyle = useAnimatedStyle(() => { + const progress = tabViewProgress?.value || 0; + const isActiveTab = animatedActiveTabIndex?.value === tabIndex; + + // Switch to using progress-based interpolation when the tab view is + // entered. This is mainly to avoid showing the close button in the + // active tab until the tab view animation is near complete. + const interpolatedOpacity = interpolate(progress, [0, 80, 100], [isActiveTab ? 0 : 1, isActiveTab ? 0 : 1, 1]); + const opacity = + !isEmptyState && (tabViewVisible?.value || !isActiveTab) ? interpolatedOpacity : withTiming(0, TIMING_CONFIGS.fastFadeConfig); + return { opacity }; + }); + + const pointerEventsStyle = useAnimatedStyle(() => { + const pointerEvents = tabViewVisible?.value && !isEmptyState ? 'auto' : 'none'; + return { pointerEvents }; + }); + + return ( + + + + {IS_IOS ? ( + + + + + ) : ( + + + + )} + + + + ); +}; + +const XIcon = ({ buttonSize, multipleTabsOpen }: { buttonSize: number; multipleTabsOpen: boolean }) => { + return ( + + 􀆄 + + ); +}; + +const styles = StyleSheet.create({ + closeButtonStyle: { + alignItems: 'center', + justifyContent: 'center', + }, + closeButtonWrapperStyle: { + position: 'absolute', + }, + containerStyle: { + zIndex: 99999999999, + }, +}); diff --git a/src/components/DappBrowser/DappBrowser.tsx b/src/components/DappBrowser/DappBrowser.tsx index 635add16387..7b1cc040423 100644 --- a/src/components/DappBrowser/DappBrowser.tsx +++ b/src/components/DappBrowser/DappBrowser.tsx @@ -1,18 +1,22 @@ import React, { useEffect, useState } from 'react'; -import Animated, { useAnimatedStyle } from 'react-native-reanimated'; -import { ScreenCornerRadius } from 'react-native-screen-corner-radius'; +import { StyleSheet } from 'react-native'; +import { ScrollView } from 'react-native-gesture-handler'; +import Animated, { interpolateColor, useAnimatedProps, useAnimatedStyle } from 'react-native-reanimated'; import RNFS from 'react-native-fs'; import { Page } from '@/components/layout'; import { Box, globalColors, useColorMode } from '@/design-system'; - +import { IS_ANDROID } from '@/env'; import { safeAreaInsetValues } from '@/utils'; import { BrowserContextProvider, useBrowserContext } from './BrowserContext'; -import { BrowserTab } from './BrowserTab'; -import { StyleSheet } from 'react-native'; +import { BrowserTab, pruneScreenshots } from './BrowserTab'; import { TAB_VIEW_ROW_HEIGHT } from './Dimensions'; -import { IS_ANDROID } from '@/env'; import { Search } from './search/Search'; +import { TabViewToolbar } from './TabViewToolbar'; +import { SheetGestureBlocker } from '../sheet/SheetGestureBlocker'; +import { ProgressBar } from './ProgressBar'; + +const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView); const getInjectedJS = async () => { const baseDirectory = IS_ANDROID ? RNFS.DocumentDirectoryPath : RNFS.MainBundlePath; @@ -42,45 +46,61 @@ const DappBrowserComponent = () => { const { scrollViewRef, tabStates, tabViewProgress, tabViewVisible } = useBrowserContext(); - const backgroundStyle = useAnimatedStyle( - () => ({ - opacity: tabViewProgress?.value ?? 0, - }), - [] - ); + useEffect(() => { + pruneScreenshots(tabStates); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const backgroundStyle = useAnimatedStyle(() => { + const progress = tabViewProgress?.value ?? 0; + + return { + backgroundColor: interpolateColor( + progress, + [0, 100], + [isDarkMode ? globalColors.grey100 : '#FBFCFD', isDarkMode ? '#0A0A0A' : '#FBFCFD'] + ), + }; + }); + + const scrollEnabledProp = useAnimatedProps(() => ({ + scrollEnabled: tabViewVisible?.value, + })); return ( - - - - {tabStates.map((tab, index) => ( - - ))} - - - + + + + + {tabStates.map((_, index) => ( + + ))} + + + + + + ); }; diff --git a/src/components/DappBrowser/DappBrowserShadows.tsx b/src/components/DappBrowser/DappBrowserShadows.tsx index 31185f80e22..82483a4a64d 100644 --- a/src/components/DappBrowser/DappBrowserShadows.tsx +++ b/src/components/DappBrowser/DappBrowserShadows.tsx @@ -1,41 +1,122 @@ import React from 'react'; +import Animated, { SharedValue, useAnimatedStyle } from 'react-native-reanimated'; import { Box, globalColors, useColorMode } from '@/design-system'; import { IS_IOS } from '@/env'; +import { useBrowserContext } from './BrowserContext'; +import { StyleSheet } from 'react-native'; -type ShadowTypes = 'button' | 'webview'; - -export const DappBrowserShadows = ({ children, type = 'button' }: { children: React.ReactNode; type?: ShadowTypes }) => { +export const BrowserButtonShadows = ({ children }: { children: React.ReactNode }) => { const { isDarkMode } = useColorMode(); if (!IS_IOS) return <>{children}; return ( {children} ); }; + +export const WebViewShadows = ({ + children, + gestureScale, + isOnHomepage, + tabIndex, +}: { + children: React.ReactNode; + gestureScale: SharedValue; + isOnHomepage: boolean; + tabIndex: number; +}) => { + const { animatedActiveTabIndex, tabViewProgress } = useBrowserContext(); + const { isDarkMode } = useColorMode(); + + const innerShadowOpacityOverride = useAnimatedStyle(() => { + const progress = tabViewProgress?.value ?? 0; + return { + ...(IS_IOS && isOnHomepage && !isDarkMode + ? { + shadowOpacity: (progress / 100) * 0.04, + } + : {}), + }; + }); + + const outerShadowOpacityOverride = useAnimatedStyle(() => { + const progress = tabViewProgress?.value ?? 0; + const isActiveTabAnimated = animatedActiveTabIndex?.value === tabIndex; + + return { + ...(IS_IOS && isOnHomepage && !isDarkMode + ? { + shadowOpacity: (progress / 100) * 0.1, + } + : {}), + zIndex: gestureScale.value * (isActiveTabAnimated || gestureScale.value > 1 ? 9999 : 1), + }; + }); + + if (!IS_IOS) + return ( + + {children} + + ); + + return ( + + + {children} + + + ); +}; + +const styles = StyleSheet.create({ + darkBackground: { + backgroundColor: globalColors.grey100, + }, + lightBackground: { + backgroundColor: '#FBFCFD', + }, +}); diff --git a/src/components/DappBrowser/Dimensions.ts b/src/components/DappBrowser/Dimensions.ts index 1a2477918ca..dd7cab11480 100644 --- a/src/components/DappBrowser/Dimensions.ts +++ b/src/components/DappBrowser/Dimensions.ts @@ -6,5 +6,9 @@ export const COLLAPSED_WEBVIEW_ASPECT_RATIO = 4 / 3; export const COLLAPSED_WEBVIEW_HEIGHT_UNSCALED = Math.min(WEBVIEW_HEIGHT, deviceUtils.dimensions.width * COLLAPSED_WEBVIEW_ASPECT_RATIO); export const TAB_VIEW_COLUMN_WIDTH = (deviceUtils.dimensions.width - 20 * 3) / 2; +export const TAB_VIEW_SINGLE_TAB_HEIGHT = deviceUtils.dimensions.width * (COLLAPSED_WEBVIEW_HEIGHT_UNSCALED / deviceUtils.dimensions.width); export const TAB_VIEW_TAB_HEIGHT = TAB_VIEW_COLUMN_WIDTH * (COLLAPSED_WEBVIEW_HEIGHT_UNSCALED / deviceUtils.dimensions.width); export const TAB_VIEW_ROW_HEIGHT = TAB_VIEW_TAB_HEIGHT + 28; + +export const INVERTED_SINGLE_TAB_SCALE = 1 / 0.7; +export const INVERTED_MULTI_TAB_SCALE = deviceUtils.dimensions.width / TAB_VIEW_COLUMN_WIDTH; diff --git a/src/components/DappBrowser/Homepage.tsx b/src/components/DappBrowser/Homepage.tsx index 9bc73ddbaaf..1c06dabfbbb 100644 --- a/src/components/DappBrowser/Homepage.tsx +++ b/src/components/DappBrowser/Homepage.tsx @@ -1,31 +1,39 @@ import { ButtonPressAnimation } from '@/components/animations'; import { Page } from '@/components/layout'; -import { Bleed, Box, Cover, Inline, Inset, Stack, Text } from '@/design-system'; -import { useNavigation } from '@/navigation'; -import { TAB_BAR_HEIGHT } from '@/navigation/SwipeNavigator'; -import { deviceUtils, safeAreaInsetValues } from '@/utils'; +import { Bleed, Box, ColorModeProvider, Cover, Inline, Inset, Stack, Text, TextIcon, globalColors, useColorMode } from '@/design-system'; +import { deviceUtils } from '@/utils'; import React from 'react'; import { ScrollView, View } from 'react-native'; import LinearGradient from 'react-native-linear-gradient'; import { BlurView } from '@react-native-community/blur'; import { ImgixImage } from '@/components/images'; import ContextMenuButton from '@/components/native-context-menu/contextMenu'; +import { IS_IOS } from '@/env'; +import { THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; +import { opacity } from '@/__swaps__/screens/Swap/utils/swaps'; import { Site } from '@/state/browserState'; import { useFavoriteDappsStore } from '@/state/favoriteDapps'; +import { TrendingSite, trendingDapps } from '@/resources/trendingDapps/trendingDapps'; +import { FadeMask } from '@/__swaps__/screens/Swap/components/FadeMask'; +import MaskedView from '@react-native-masked-view/masked-view'; +import { useBrowserContext } from './BrowserContext'; +import { GestureHandlerV1Button } from '@/__swaps__/screens/Swap/components/GestureHandlerV1Button'; +import { isEmpty } from 'lodash'; -const HORIZONTAL_INSET = 24; +const HORIZONTAL_PAGE_INSET = 24; -const NUM_LOGOS = 5; -const LOGO_PADDING = 14; -const LOGO_SIZE = (deviceUtils.dimensions.width - HORIZONTAL_INSET * 2 - (NUM_LOGOS - 1) * LOGO_PADDING) / NUM_LOGOS; +const LOGOS_PER_ROW = 4; +const LOGO_SIZE = 64; +const LOGO_PADDING = (deviceUtils.dimensions.width - LOGOS_PER_ROW * LOGO_SIZE - HORIZONTAL_PAGE_INSET * 2) / (LOGOS_PER_ROW - 1); +const LOGO_BORDER_RADIUS = 16; +const LOGO_LABEL_SPILLOVER = 12; const NUM_CARDS = 2; const CARD_PADDING = 12; -const CARD_SIZE = (deviceUtils.dimensions.width - HORIZONTAL_INSET * 2 - (NUM_CARDS - 1) * CARD_PADDING) / NUM_CARDS; +const CARD_SIZE = (deviceUtils.dimensions.width - HORIZONTAL_PAGE_INSET * 2 - (NUM_CARDS - 1) * CARD_PADDING) / NUM_CARDS; -const Card = () => { - const bgImageUrl = 'https://nftcalendar.io/storage/uploads/2022/05/06/banner_discord1_05062022181527627565bf3c203.jpeg'; - const logoImageUrl = 'https://pbs.twimg.com/profile_images/1741494128779886592/RY4V0T2F_400x400.jpg'; +const Card = ({ site, showMenuButton }: { showMenuButton?: boolean; site: TrendingSite }) => { + const { isDarkMode } = useColorMode(); const menuConfig = { menuTitle: '', @@ -50,138 +58,221 @@ const Card = () => { }; return ( - - + + - {bgImageUrl && ( - - + + {site.screenshot && ( - + + + - - )} - - - - Rainbowcast - - - zora.co - - - - - + - - - - 􀍠 + + + + {site.name} - - + + {site.url} + + + {showMenuButton && ( + {}} + style={{ top: 12, right: 12, height: 24, width: 24, position: 'absolute' }} + > + + + + {IS_IOS ? ( + + ) : ( + + )} + + + + 􀍠 + + + + + + )} + - - {}} - style={{ top: 12, right: 12, height: 24, width: 24, position: 'absolute' }} - /> - + {IS_IOS && ( + + )} + + ); }; const Logo = ({ site }: { site: Omit }) => { + const { updateActiveTabState } = useBrowserContext(); + const { isDarkMode } = useColorMode(); + return ( - - - - - {site.name} - + updateActiveTabState({ url: site.url })}> + + + {IS_IOS && !isEmpty(site.image) && ( + + + 􀎭 + + + )} + + {IS_IOS && ( + + )} + + + } + style={{ width: LOGO_SIZE + LOGO_LABEL_SPILLOVER * 2 }} + > + + {site.name} + + + - + ); }; export default function Homepage() { - const { navigate } = useNavigation(); + const { isDarkMode } = useColorMode(); const { favoriteDapps } = useFavoriteDappsStore(); return ( - + @@ -199,9 +290,9 @@ export default function Homepage() { - - - + {trendingDapps.map(site => ( + + ))} @@ -217,17 +308,16 @@ export default function Homepage() { Favorites - - - - - {favoriteDapps.map(dapp => ( - - ))} - - - - + + {favoriteDapps.map(dapp => ( + + ))} + )} @@ -240,9 +330,9 @@ export default function Homepage() { - - - + {trendingDapps.map(site => ( + + ))} diff --git a/src/components/DappBrowser/ProgressBar.tsx b/src/components/DappBrowser/ProgressBar.tsx new file mode 100644 index 00000000000..8472f078e06 --- /dev/null +++ b/src/components/DappBrowser/ProgressBar.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; +import Animated, { useAnimatedStyle, withSpring, withTiming } from 'react-native-reanimated'; +import { SPRING_CONFIGS, TIMING_CONFIGS } from '@/components/animations/animationConfigs'; +import { Box } from '@/design-system'; +import { useAccountAccentColor } from '@/hooks'; +import { deviceUtils, safeAreaInsetValues } from '@/utils'; +import { useBrowserContext } from './BrowserContext'; +import { WEBVIEW_HEIGHT } from './Dimensions'; + +export const ProgressBar = () => { + const { accentColor } = useAccountAccentColor(); + const { loadProgress, tabViewVisible } = useBrowserContext(); + + const progressBarStyle = useAnimatedStyle(() => ({ + // eslint-disable-next-line no-nested-ternary + opacity: tabViewVisible?.value + ? withSpring(0, SPRING_CONFIGS.snappierSpringConfig) + : loadProgress?.value === 1 + ? withTiming(0, TIMING_CONFIGS.slowestFadeConfig) + : withSpring(1, SPRING_CONFIGS.snappierSpringConfig), + width: (loadProgress?.value || 0) * deviceUtils.dimensions.width, + })); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + centerAlign: { + justifyContent: 'center', + alignItems: 'center', + }, + progressBar: { + borderRadius: 1, + height: 2, + top: WEBVIEW_HEIGHT + safeAreaInsetValues.top + 88 - 2, + left: 0, + width: deviceUtils.dimensions.width, + pointerEvents: 'none', + position: 'absolute', + zIndex: 10000, + }, +}); diff --git a/src/components/DappBrowser/TabViewToolbar.tsx b/src/components/DappBrowser/TabViewToolbar.tsx new file mode 100644 index 00000000000..7839cc704be --- /dev/null +++ b/src/components/DappBrowser/TabViewToolbar.tsx @@ -0,0 +1,187 @@ +import { BlurView } from '@react-native-community/blur'; +import React from 'react'; +import { StyleProp, ViewStyle } from 'react-native'; +import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated'; +import { ButtonPressAnimation } from '@/components/animations'; +import { Bleed, Box, BoxProps, Text, globalColors, useColorMode, useForegroundColor } from '@/design-system'; +import { TextColor } from '@/design-system/color/palettes'; +import { TextWeight } from '@/design-system/components/Text/Text'; +import { TextSize } from '@/design-system/typography/typeHierarchy'; +import { IS_IOS } from '@/env'; +import { useDimensions } from '@/hooks'; +import * as i18n from '@/languages'; +import { TAB_BAR_HEIGHT } from '@/navigation/SwipeNavigator'; +import { position } from '@/styles'; +import { GestureHandlerV1Button } from '@/__swaps__/screens/Swap/components/GestureHandlerV1Button'; +import { THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; +import { opacity } from '@/__swaps__/screens/Swap/utils/swaps'; +import { useBrowserContext } from './BrowserContext'; +import { BrowserButtonShadows } from './DappBrowserShadows'; + +export const TabViewToolbar = () => { + const { width: deviceWidth } = useDimensions(); + const { tabViewProgress, tabViewVisible } = useBrowserContext(); + + const barStyle = useAnimatedStyle(() => { + const progress = tabViewProgress?.value || 0; + + return { + opacity: progress / 75, + pointerEvents: tabViewVisible?.value ? 'box-none' : 'none', + transform: [ + { + scale: interpolate(progress, [0, 100], [0.95, 1]), + }, + ], + }; + }); + + return ( + + + + + + + ); +}; + +const NewTabButton = () => { + const { newTab } = useBrowserContext(); + + return ; +}; + +const DoneButton = () => { + const { toggleTabViewWorklet } = useBrowserContext(); + + return ( + + + {i18n.t(i18n.l.button.done)} + + + ); +}; + +type BaseButtonProps = { + children?: React.ReactNode; + icon?: string; + iconColor?: TextColor; + iconSize?: TextSize; + iconWeight?: TextWeight; + onPress?: () => void; + onPressWorklet?: () => void; + paddingHorizontal?: BoxProps['paddingHorizontal']; + scaleTo?: number; + width?: number; +}; + +const BaseButton = ({ + children, + icon, + iconColor = 'labelSecondary', + iconSize = 'icon 17px', + iconWeight = 'heavy', + onPress, + onPressWorklet, + paddingHorizontal = '16px', + scaleTo, + width, +}: BaseButtonProps) => { + const { isDarkMode } = useColorMode(); + const fillSecondary = useForegroundColor('fillSecondary'); + const separatorSecondary = useForegroundColor('separatorSecondary'); + + const buttonColorIOS = isDarkMode ? fillSecondary : opacity(globalColors.white100, 0.9); + const buttonColorAndroid = isDarkMode ? globalColors.blueGrey100 : globalColors.white100; + const buttonColor = IS_IOS ? buttonColorIOS : buttonColorAndroid; + + return ( + + + + + {children || ( + + {icon} + + )} + {IS_IOS && ( + + )} + + + + + + ); +}; + +type HybridButtonProps = { + children?: React.ReactNode; + onPress?: () => void; + onPressWorklet?: () => void; + scaleTo?: number; + style?: StyleProp; +}; + +const HybridWorkletButton = ({ children, onPress, onPressWorklet, scaleTo, style }: HybridButtonProps) => { + if (onPressWorklet) { + return ( + + {children} + + ); + } else if (onPress) { + return ( + + {children} + + ); + } else { + return <>{children}; + } +}; diff --git a/src/components/DappBrowser/ToolbarIcon.tsx b/src/components/DappBrowser/ToolbarIcon.tsx new file mode 100644 index 00000000000..8e10430c315 --- /dev/null +++ b/src/components/DappBrowser/ToolbarIcon.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { StyleSheet, TouchableOpacity } from 'react-native'; +import { ButtonPressAnimation } from '@/components/animations'; +import { Bleed, Box, Text, TextIcon, useForegroundColor } from '@/design-system'; +import { TextColor } from '@/design-system/color/palettes'; +import { TextWeight } from '@/design-system/components/Text/Text'; +import { TextSize } from '@/design-system/typography/typeHierarchy'; +import { useTheme } from '@/theme'; + +export const ToolbarIcon = ({ + color, + disabled, + icon, + onPress, + scaleTo, + side, + size = 'icon 17px', + weight = 'bold', +}: { + color?: TextColor; + disabled?: boolean; + icon: string; + onPress: () => void; + scaleTo?: number; + side?: 'left' | 'right'; + size?: TextSize; + weight?: TextWeight; +}) => { + return ( + + + {icon} + + + ); +}; + +export const ToolbarTextButton = ({ + color, + disabled, + label, + onPress, + showBackground, + textAlign, +}: { + color?: TextColor; + disabled?: boolean; + label: string; + onPress: () => void; + showBackground?: boolean; + textAlign?: 'center' | 'left' | 'right'; +}) => { + const { colors } = useTheme(); + const hexColor = useForegroundColor(color || 'blue'); + + return ( + + + + + {label} + + + + + ); +}; + +const styles = StyleSheet.create({ + buttonPressWrapper: { + alignItems: 'center', + height: 48, + justifyContent: 'center', + width: 40, + }, + leftSidePadding: { + paddingLeft: 4, + }, + rightSidePadding: { + paddingRight: 4, + }, +}); diff --git a/src/components/DappBrowser/WebViewBorder.tsx b/src/components/DappBrowser/WebViewBorder.tsx index 8fcbab87274..bf998c47966 100644 --- a/src/components/DappBrowser/WebViewBorder.tsx +++ b/src/components/DappBrowser/WebViewBorder.tsx @@ -2,20 +2,18 @@ import React from 'react'; import { StyleSheet } from 'react-native'; import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated'; import { Box, Cover, globalColors } from '@/design-system'; -import { IS_ANDROID } from '@/env'; import { THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; import { opacity } from '@/__swaps__/screens/Swap/utils/swaps'; import { useBrowserContext } from './BrowserContext'; +import { WEBVIEW_HEIGHT } from './Dimensions'; -export const WebViewBorder = ({ enabled, isActiveTab }: { enabled?: boolean; isActiveTab: boolean }) => { - const { tabViewProgress } = useBrowserContext(); +export const WebViewBorder = ({ enabled, tabIndex }: { enabled?: boolean; tabIndex: number }) => { + const { animatedActiveTabIndex, tabViewProgress } = useBrowserContext(); const webViewBorderStyle = useAnimatedStyle(() => { - const progress = tabViewProgress?.value ?? 0; - - // eslint-disable-next-line no-nested-ternary - const borderRadius = interpolate(progress, [0, 1], [isActiveTab ? (IS_ANDROID ? 0 : 16) : 30, 30]); - const opacity = interpolate(progress, [0, 1], [1, 0]); + const progress = tabViewProgress?.value || 0; + const borderRadius = interpolate(progress, [0, 100], [animatedActiveTabIndex?.value === tabIndex ? 16 : 30, 30], 'clamp'); + const opacity = 1 - progress / 100; return { borderRadius, @@ -24,16 +22,23 @@ export const WebViewBorder = ({ enabled, isActiveTab }: { enabled?: boolean; isA }); return enabled ? ( - - + + ) : null; }; const styles = StyleSheet.create({ webViewBorderStyle: { + backgroundColor: 'transparent', borderColor: opacity(globalColors.white100, 0.08), + borderCurve: 'continuous', + borderRadius: 16, borderWidth: THICK_BORDER_WIDTH, + overflow: 'hidden', pointerEvents: 'none', }, + zIndexStyle: { + zIndex: 30000, + }, }); diff --git a/src/components/DappBrowser/constants.ts b/src/components/DappBrowser/constants.ts new file mode 100644 index 00000000000..c4772e7c5a5 --- /dev/null +++ b/src/components/DappBrowser/constants.ts @@ -0,0 +1,16 @@ +import { ImageOptions } from '@candlefinance/faster-image'; + +export const GOOGLE_SEARCH_URL = 'https://www.google.com/search?q='; +export const HTTP = 'http://'; +export const HTTPS = 'https://'; + +const BLANK_BASE64_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + +export const FASTER_IMAGE_CONFIG: Partial = { + // This placeholder avoids an occasional loading spinner flash + base64Placeholder: BLANK_BASE64_PIXEL, + cachePolicy: 'discNoCacheControl', + resizeMode: 'cover', + showActivityIndicator: false, + transitionDuration: 0, +}; diff --git a/src/components/DappBrowser/search-input/AccountIcon.tsx b/src/components/DappBrowser/search-input/AccountIcon.tsx index d8a3f06e97a..60eae3b3e4b 100644 --- a/src/components/DappBrowser/search-input/AccountIcon.tsx +++ b/src/components/DappBrowser/search-input/AccountIcon.tsx @@ -1,12 +1,13 @@ import React, { useCallback, useMemo } from 'react'; import { useAccountSettings, useWallets } from '@/hooks'; -import { useNavigation } from '@react-navigation/native'; +import { useNavigation } from '@/navigation'; import ButtonPressAnimation from '@/components/animations/ButtonPressAnimation'; import ImageAvatar from '@/components/contacts/ImageAvatar'; import { findWalletWithAccount } from '@/helpers/findWalletWithAccount'; import { getAccountProfileInfo } from '@/helpers/accountInfo'; import Routes from '@/navigation/routesNames'; import { ContactAvatar } from '@/components/contacts'; +import { Bleed } from '@/design-system'; export const AccountIcon = () => { const { navigate } = useNavigation(); @@ -14,14 +15,8 @@ export const AccountIcon = () => { const { wallets, walletNames } = useWallets(); const handlePressChangeWallet = useCallback(() => { - navigate(Routes.CHANGE_WALLET_SHEET, { - currentAccountAddress: accountAddress, - onChangeWallet: address => { - // TODO plug in when we have sessions hooked up - }, - watchOnly: true, - }); - }, [accountAddress, navigate]); + navigate(Routes.CHANGE_WALLET_SHEET); + }, [navigate]); // TODO: use dapp specifc address const accountInfo = useMemo(() => { @@ -33,12 +28,14 @@ export const AccountIcon = () => { }, [wallets, accountAddress, walletNames]); return ( - - {accountInfo?.accountImage ? ( - - ) : ( - - )} - + + + {accountInfo?.accountImage ? ( + + ) : ( + + )} + + ); }; diff --git a/src/components/DappBrowser/search-input/SearchInput.tsx b/src/components/DappBrowser/search-input/SearchInput.tsx index 727fa487712..10d6bb4518c 100644 --- a/src/components/DappBrowser/search-input/SearchInput.tsx +++ b/src/components/DappBrowser/search-input/SearchInput.tsx @@ -1,153 +1,126 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { RefObject, useCallback, useMemo } from 'react'; import MaskedView from '@react-native-masked-view/masked-view'; -import { Box, globalColors, useColorMode, useForegroundColor } from '@/design-system'; -import { ButtonPressAnimation } from '@/components/animations'; -import Animated, { useAnimatedStyle } from 'react-native-reanimated'; -import { BlurView } from '@react-native-community/blur'; +import { AnimatedText, Box, Cover, globalColors, useColorMode, useForegroundColor } from '@/design-system'; +import Animated, { SharedValue, useAnimatedStyle, useDerivedValue, withSpring, withTiming } from 'react-native-reanimated'; import Input from '@/components/inputs/Input'; import * as i18n from '@/languages'; -import { NativeSyntheticEvent, Share, TextInputSubmitEditingEventData } from 'react-native'; -import { ToolbarIcon } from '../BrowserToolbar'; +import { NativeSyntheticEvent, StyleSheet, TextInput, TextInputFocusEventData, TextInputSubmitEditingEventData } from 'react-native'; +import { ToolbarIcon } from '../ToolbarIcon'; import { IS_IOS } from '@/env'; import { FadeMask } from '@/__swaps__/screens/Swap/components/FadeMask'; import { THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; -import { DappBrowserShadows } from '../DappBrowserShadows'; -import { RAINBOW_HOME, useBrowserContext } from '../BrowserContext'; -import isValidDomain from 'is-valid-domain'; import { opacity } from '@/__swaps__/screens/Swap/utils/swaps'; +import { BrowserButtonShadows } from '../DappBrowserShadows'; +import { GestureHandlerV1Button } from '@/__swaps__/screens/Swap/components/GestureHandlerV1Button'; +import font from '@/styles/fonts'; +import { fontWithWidth } from '@/styles'; +import { useBrowserContext } from '../BrowserContext'; +import { SPRING_CONFIGS, TIMING_CONFIGS } from '@/components/animations/animationConfigs'; +import { AnimatedBlurView } from '@/__swaps__/screens/Swap/components/AnimatedBlurView'; +import haptics from '@/utils/haptics'; import { useFavoriteDappsStore } from '@/state/favoriteDapps'; import { Site } from '@/state/browserState'; import ContextMenuButton from '@/components/native-context-menu/contextMenu'; -import ConditionalWrap from 'conditional-wrap'; -import haptics from '@/utils/haptics'; - -const GOOGLE_SEARCH_URL = 'https://www.google.com/search?q='; -const HTTP = 'http://'; -const HTTPS = 'https://'; +import { getNameFromFormattedUrl, handleShareUrl } from '../utils'; const AnimatedInput = Animated.createAnimatedComponent(Input); -export const SearchInput = () => { - const { - isSearchInputFocused, - searchInputRef, - tabStates, - activeTabIndex, - tabViewVisible, - searchViewProgress, - setIsSearchInputFocused, - updateActiveTabState, - onRefresh, - } = useBrowserContext(); +export const SearchInput = ({ + inputRef, + formattedInputValue, + inputValue, + isGoogleSearch, + isHome, + onPressWorklet, + onBlur, + onSubmitEditing, + isFocused, + isFocusedValue, + logoUrl, + canGoBack, + canGoForward, +}: { + inputRef: RefObject; + formattedInputValue: { value: string; tabIndex: number }; + inputValue: string | undefined; + isGoogleSearch: boolean; + isHome: boolean; + onPressWorklet: () => void; + onBlur: (event: NativeSyntheticEvent) => void; + onSubmitEditing: (event: NativeSyntheticEvent) => void; + isFocused: boolean; + isFocusedValue: SharedValue; + logoUrl: string | undefined | null; + canGoBack: boolean; + canGoForward: boolean; +}) => { + const { animatedActiveTabIndex, goBack, goForward, onRefresh, tabViewProgress } = useBrowserContext(); const { isFavorite, addFavorite, removeFavorite } = useFavoriteDappsStore(); const { isDarkMode } = useColorMode(); const fillSecondary = useForegroundColor('fillSecondary'); - const labelSecondary = useForegroundColor('labelSecondary'); + const label = useForegroundColor('label'); const labelQuaternary = useForegroundColor('labelQuaternary'); const separatorSecondary = useForegroundColor('separatorSecondary'); - const [url, setUrl] = useState(tabStates[activeTabIndex].url); - - const handleUrlSubmit = (event: NativeSyntheticEvent) => { - let newUrl = event.nativeEvent.text; - - let urlForValidation = newUrl.replace(/^https?:\/\//, ''); - if (urlForValidation.endsWith('/')) { - urlForValidation = urlForValidation.slice(0, -1); - } - - if (!isValidDomain(urlForValidation, { wildcard: true })) { - newUrl = GOOGLE_SEARCH_URL + newUrl; - } else if (!newUrl.startsWith(HTTP) && !newUrl.startsWith(HTTPS)) { - newUrl = HTTPS + newUrl; - } - - updateActiveTabState(activeTabIndex, { url: newUrl }); - }; - - const formattedUrl = useMemo(() => { - try { - const { hostname, pathname, search } = new URL(url); - if (hostname === 'www.google.com' && pathname === '/search') { - const params = new URLSearchParams(search); - return params.get('q') || url; - } - return hostname.startsWith('www.') ? hostname.slice(4) : hostname; - } catch { - return url; - } - }, [url]); - - // url handling needs work - useEffect(() => { - if (tabStates[activeTabIndex].url !== url) { - setUrl(tabStates[activeTabIndex].url); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTabIndex, tabStates]); - - const onUrlChange = (newUrl: string) => { - setUrl(newUrl); - }; - - const isGoogleSearch = url.startsWith(GOOGLE_SEARCH_URL); - const isHome = formattedUrl === RAINBOW_HOME; - // eslint-disable-next-line no-nested-ternary - const inputValue = isHome ? undefined : isSearchInputFocused && !isGoogleSearch ? url : formattedUrl; - const buttonColorIOS = isDarkMode ? fillSecondary : opacity(globalColors.white100, 0.9); const buttonColorAndroid = isDarkMode ? globalColors.blueGrey100 : globalColors.white100; const buttonColor = IS_IOS ? buttonColorIOS : buttonColorAndroid; - const isOnHomepage = tabStates[activeTabIndex].url === RAINBOW_HOME; + const formattedUrl = formattedInputValue?.value; + const formattedUrlValue = useDerivedValue(() => { + return formattedInputValue?.tabIndex !== animatedActiveTabIndex?.value ? '' : formattedInputValue?.value; + }); + + const pointerEventsStyle = useAnimatedStyle(() => ({ + pointerEvents: (tabViewProgress?.value || 0) / 100 < 1 ? 'auto' : 'none', + })); + + const buttonWrapperStyle = useAnimatedStyle(() => ({ + pointerEvents: isFocusedValue.value ? 'auto' : 'box-only', + })); const inputStyle = useAnimatedStyle(() => ({ - paddingLeft: 16 + (searchViewProgress?.value ?? 0) * 24, + opacity: isFocusedValue.value ? withSpring(1, SPRING_CONFIGS.keyboardConfig) : withTiming(0, TIMING_CONFIGS.fadeConfig), + pointerEvents: isFocusedValue.value ? 'auto' : 'none', })); - const onPress = useCallback(() => { - if (!isSearchInputFocused) { - setIsSearchInputFocused(true); - setTimeout(() => { - searchInputRef.current?.focus(); - }, 50); - } - }, [isSearchInputFocused, searchInputRef, setIsSearchInputFocused]); + const formattedInputStyle = useAnimatedStyle(() => ({ + opacity: isFocusedValue.value ? withTiming(0, TIMING_CONFIGS.fadeConfig) : withSpring(1, SPRING_CONFIGS.keyboardConfig), + })); - const onBlur = useCallback(() => { - setIsSearchInputFocused(false); - }, [setIsSearchInputFocused]); + const hideFormattedUrlWhenTabChanges = useAnimatedStyle(() => ({ + opacity: withSpring(formattedInputValue?.tabIndex !== animatedActiveTabIndex?.value ? 0 : 1, SPRING_CONFIGS.snappierSpringConfig), + })); - const onFocus = useCallback(() => { - setIsSearchInputFocused(true); - }, [setIsSearchInputFocused]); + const toolbarIconStyle = useAnimatedStyle(() => ({ + opacity: + isHome || isFocusedValue.value || !formattedUrlValue.value + ? withTiming(0, TIMING_CONFIGS.fadeConfig) + : withSpring(1, SPRING_CONFIGS.keyboardConfig), + pointerEvents: isHome || isFocusedValue.value || !formattedUrlValue.value ? 'none' : 'auto', + })); const handleFavoritePress = useCallback(() => { - if (isFavorite(formattedUrl)) { - removeFavorite(formattedUrl); - } else { - const site: Omit = { - name: formattedUrl, - url: formattedUrl, - image: `${formattedUrl}/favicon.ico`, - }; - addFavorite(site); + if (inputValue) { + if (isFavorite(inputValue)) { + removeFavorite(inputValue); + } else { + const site: Omit = { + name: getNameFromFormattedUrl(formattedUrl), + url: inputValue, + image: logoUrl || `https://${formattedUrl}/apple-touch-icon.png`, + }; + addFavorite(site); + } } - }, [formattedUrl]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formattedUrl, inputValue, logoUrl]); const menuConfig = useMemo( () => ({ menuTitle: '', menuItems: [ - { - actionKey: 'favorite', - actionTitle: isFavorite(formattedUrl) ? 'Undo Favorite' : 'Favorite', - icon: { - iconType: 'SYSTEM', - iconValue: isFavorite(formattedUrl) ? 'star.slash' : 'star', - }, - }, { actionKey: 'share', actionTitle: 'Share', @@ -156,43 +129,89 @@ export const SearchInput = () => { iconValue: 'square.and.arrow.up', }, }, + !isGoogleSearch + ? { + actionKey: 'favorite', + actionTitle: isFavorite(formattedUrl) ? 'Undo Favorite' : 'Favorite', + icon: { + iconType: 'SYSTEM', + iconValue: isFavorite(formattedUrl) ? 'star.slash' : 'star', + }, + } + : {}, + canGoForward + ? { + actionKey: 'forward', + actionTitle: 'Forward', + icon: { + iconType: 'SYSTEM', + iconValue: 'arrowshape.forward', + }, + } + : {}, + canGoBack + ? { + actionKey: 'back', + actionTitle: 'Back', + icon: { + iconType: 'SYSTEM', + iconValue: 'arrowshape.backward', + }, + } + : {}, ], }), - [isFavorite(formattedUrl)] + // eslint-disable-next-line react-hooks/exhaustive-deps + [canGoBack, canGoForward, isFavorite(formattedUrl), isGoogleSearch] ); - const onPressMenuItem = async ({ nativeEvent: { actionKey } }: { nativeEvent: { actionKey: 'share' | 'favorite' } }) => { + const onPressMenuItem = async ({ + nativeEvent: { actionKey }, + }: { + nativeEvent: { actionKey: 'share' | 'favorite' | 'back' | 'forward' }; + }) => { haptics.selection(); if (actionKey === 'favorite') { handleFavoritePress(); - } else { - await Share.share({ message: url }); + } else if (actionKey === 'back') { + goBack(); + } else if (actionKey === 'forward') { + goForward(); + } else if (inputValue) { + handleShareUrl(inputValue); } }; return ( - - - + + } style={{ alignItems: 'center', flex: 1, flexDirection: 'row', + height: 48, justifyContent: 'center', zIndex: 99, }} @@ -201,79 +220,102 @@ export const SearchInput = () => { clearButtonMode="while-editing" enablesReturnKeyAutomatically keyboardType="web-search" - // i18n placeholder={i18n.t(i18n.l.dapp_browser.address_bar.input_placeholder)} placeholderTextColor={labelQuaternary} onBlur={onBlur} - onChangeText={onUrlChange} - onFocus={onFocus} - onSubmitEditing={handleUrlSubmit} - ref={searchInputRef} + onSubmitEditing={onSubmitEditing} + ref={inputRef} returnKeyType="go" selectTextOnFocus spellCheck={false} style={[ inputStyle, + styles.input, { - color: labelSecondary, - flex: 1, - fontSize: 17, - fontWeight: '700', - height: 48, - marginRight: 8, - paddingRight: 8, - paddingVertical: 10, - pointerEvents: isSearchInputFocused ? 'auto' : 'none', - textAlign: isSearchInputFocused ? 'left' : 'center', - elevation: 99, + color: label, }, ]} - value={inputValue} + textAlign="left" + textAlignVertical="center" + defaultValue={inputValue} /> + + + + {formattedUrlValue} + + + {IS_IOS && ( )} - - {(isSearchInputFocused || !isOnHomepage) && ( - - ( - - {children} - - )} - > - - - - )} - {!isSearchInputFocused && !isOnHomepage && ( - - - - )} + + + + { + return; + }} + side="left" + size="icon 17px" + weight="heavy" + /> + + + + + - + ); }; + +const styles = StyleSheet.create({ + input: { + flex: 1, + fontSize: 20, + height: IS_IOS ? 48 : 60, + letterSpacing: 0.36, + lineHeight: IS_IOS ? undefined : 24, + marginRight: 7, + paddingLeft: 16, + paddingRight: 9, + paddingVertical: 10, + ...fontWithWidth(font.weight.semibold), + }, +}); diff --git a/src/components/DappBrowser/search-input/TabButton.tsx b/src/components/DappBrowser/search-input/TabButton.tsx index 8135f45f9fa..692b945b5d7 100644 --- a/src/components/DappBrowser/search-input/TabButton.tsx +++ b/src/components/DappBrowser/search-input/TabButton.tsx @@ -1,16 +1,28 @@ import { THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; import { opacity } from '@/__swaps__/screens/Swap/utils/swaps'; -import ButtonPressAnimation from '@/components/animations/ButtonPressAnimation'; -import { Box, Text, globalColors, useColorMode, useForegroundColor } from '@/design-system'; +import { Bleed, Box, Text, globalColors, useColorMode, useForegroundColor } from '@/design-system'; import { IS_IOS } from '@/env'; import position from '@/styles/position'; import { BlurView } from '@react-native-community/blur'; -import React, { useCallback } from 'react'; -import { DappBrowserShadows } from '../DappBrowserShadows'; +import React from 'react'; +import { TextInput } from 'react-native'; +import { BrowserButtonShadows } from '../DappBrowserShadows'; +import { GestureHandlerV1Button } from '@/__swaps__/screens/Swap/components/GestureHandlerV1Button'; +import { AnimatedRef, SharedValue, dispatchCommand, runOnJS } from 'react-native-reanimated'; import { useBrowserContext } from '../BrowserContext'; -export const TabButton = ({ toggleTabView }: { toggleTabView: () => void }) => { - const { isSearchInputFocused, searchInputRef } = useBrowserContext(); +export const TabButton = ({ + inputRef, + isFocused, + isFocusedValue, + setIsFocused, +}: { + inputRef: AnimatedRef; + isFocused: boolean; + isFocusedValue: SharedValue; + setIsFocused: React.Dispatch>; +}) => { + const { toggleTabViewWorklet } = useBrowserContext(); const { isDarkMode } = useColorMode(); const fillSecondary = useForegroundColor('fillSecondary'); const separatorSecondary = useForegroundColor('separatorSecondary'); @@ -19,57 +31,59 @@ export const TabButton = ({ toggleTabView }: { toggleTabView: () => void }) => { const buttonColorAndroid = isDarkMode ? globalColors.blueGrey100 : globalColors.white100; const buttonColor = IS_IOS ? buttonColorIOS : buttonColorAndroid; - const onPress = useCallback(() => { - if (!isSearchInputFocused) { - // open tabs - toggleTabView(); + const onPress = () => { + 'worklet'; + if (!isFocusedValue.value) { + toggleTabViewWorklet(); } else { - // close keyboard - searchInputRef?.current?.blur(); + runOnJS(setIsFocused)(false); + dispatchCommand(inputRef, 'blur'); } - }, [isSearchInputFocused, searchInputRef, toggleTabView]); + }; return ( - - - - {isSearchInputFocused ? '􀆈' : '􀐅'} - - {IS_IOS && ( + + + - )} - - - + borderRadius={22} + style={{ height: 44, paddingTop: isFocused ? 1 : undefined, width: 44 }} + alignItems="center" + justifyContent="center" + > + + {isFocused ? '􀆈' : '􀐅'} + + {IS_IOS && ( + + )} + + + + + ); }; diff --git a/src/components/DappBrowser/search/SearchBar.tsx b/src/components/DappBrowser/search/SearchBar.tsx index 007f84d9547..cd240e730a9 100644 --- a/src/components/DappBrowser/search/SearchBar.tsx +++ b/src/components/DappBrowser/search/SearchBar.tsx @@ -1,49 +1,144 @@ -import React from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; +import { NativeSyntheticEvent, TextInput, TextInputSubmitEditingEventData } from 'react-native'; +import Animated, { + dispatchCommand, + interpolate, + runOnJS, + useAnimatedRef, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import { SPRING_CONFIGS } from '@/components/animations/animationConfigs'; import { Box } from '@/design-system'; -import Animated, { Easing, interpolate, useAnimatedKeyboard, useAnimatedStyle, withTiming } from 'react-native-reanimated'; +import { IS_IOS } from '@/env'; +import { useKeyboardHeight, useDimensions } from '@/hooks'; +import * as i18n from '@/languages'; import { TAB_BAR_HEIGHT } from '@/navigation/SwipeNavigator'; -import { useBrowserContext } from '../BrowserContext'; +import { RAINBOW_HOME, useBrowserContext } from '../BrowserContext'; +import { GOOGLE_SEARCH_URL, HTTP, HTTPS } from '../constants'; import { AccountIcon } from '../search-input/AccountIcon'; -import { TabButton } from '../search-input/TabButton'; import { SearchInput } from '../search-input/SearchInput'; -import useDimensions from '@/hooks/useDimensions'; -import { IS_IOS } from '@/env'; - -const timingConfig = { - duration: 500, - easing: Easing.bezier(0.22, 1, 0.36, 1), -}; +import { TabButton } from '../search-input/TabButton'; +import { isValidURL } from '../utils'; export const SearchBar = () => { - const { tabViewProgress, toggleTabView, isSearchInputFocused, searchViewProgress } = useBrowserContext(); - - const keyboard = useAnimatedKeyboard(); const { width: deviceWidth } = useDimensions(); + const { activeTabIndex, onRefresh, searchViewProgress, tabStates, tabViewProgress, tabViewVisible, updateActiveTabState } = + useBrowserContext(); - const barStyle = useAnimatedStyle(() => ({ - opacity: 1 - (tabViewProgress?.value ?? 0), - paddingLeft: withTiming(72 - 56 * (searchViewProgress?.value ?? 0), timingConfig), - pointerEvents: (tabViewProgress?.value ?? 0) < 1 ? 'auto' : 'none', - transform: [ - { - translateY: interpolate(tabViewProgress?.value ?? 0, [0, 1], [0, 68], 'clamp'), - }, - { - scale: interpolate(tabViewProgress?.value ?? 0, [0, 1], [1, 0.9], 'clamp'), - }, - ], - })); + const isFocusedValue = useSharedValue(false); + const [isFocused, setIsFocused] = useState(false); + + const keyboardHeight = useKeyboardHeight({ shouldListen: isFocused }); + const inputRef = useAnimatedRef(); + + const tabId = tabStates?.[activeTabIndex]?.uniqueId; + const url = tabStates?.[activeTabIndex]?.url; + const logoUrl = tabStates?.[activeTabIndex]?.logoUrl; + const isHome = url === RAINBOW_HOME; + const isGoogleSearch = url?.startsWith(GOOGLE_SEARCH_URL); + const canGoBack = tabStates?.[activeTabIndex]?.canGoBack; + const canGoForward = tabStates?.[activeTabIndex]?.canGoForward; + + const formattedInputValue = useMemo(() => { + if (isHome) { + return { value: i18n.t(i18n.l.dapp_browser.address_bar.input_placeholder), tabIndex: activeTabIndex }; + } + + let formattedValue = ''; + try { + const { hostname, pathname, search } = new URL(url); + if (hostname === 'www.google.com' && pathname === '/search') { + const params = new URLSearchParams(search); + formattedValue = params.get('q') || ''; + } else { + formattedValue = hostname.startsWith('www.') ? hostname.slice(4) : hostname; + } + } catch { + if (!isGoogleSearch) { + formattedValue = url; + } + } + return { value: formattedValue, tabIndex: activeTabIndex }; + }, [activeTabIndex, isGoogleSearch, isHome, url]); + + const urlWithoutTrailingSlash = url?.endsWith('/') ? url.slice(0, -1) : url; + // eslint-disable-next-line no-nested-ternary + const inputValue = isHome ? undefined : isGoogleSearch ? formattedInputValue.value : urlWithoutTrailingSlash; + + const barStyle = useAnimatedStyle(() => { + const progress = tabViewProgress?.value ?? 0; + + return { + opacity: 1 - progress / 75, + paddingLeft: withSpring(isFocusedValue.value ? 16 : 72, SPRING_CONFIGS.keyboardConfig), + pointerEvents: tabViewVisible?.value ? 'none' : 'auto', + transform: [ + { + scale: interpolate(progress, [0, 100], [1, 0.95]), + }, + ], + }; + }); const accountIconStyle = useAnimatedStyle(() => ({ - opacity: 1 - (searchViewProgress?.value ?? 0), + opacity: withSpring(isFocusedValue.value ? 0 : 1, SPRING_CONFIGS.keyboardConfig), + pointerEvents: isFocusedValue.value ? 'none' : 'auto', })); const bottomBarStyle = useAnimatedStyle(() => { + const translateY = isFocusedValue.value ? -(keyboardHeight - (IS_IOS ? 82 : 46)) : 0; + return { - height: TAB_BAR_HEIGHT + 88, - transform: [{ translateY: Math.min(-(keyboard.height.value - (IS_IOS ? 82 : 46)), 0) }], + transform: [ + { + translateY: withSpring(translateY, SPRING_CONFIGS.keyboardConfig), + }, + ], }; - }, [tabViewProgress, keyboard.height]); + }); + + const handleUrlSubmit = useCallback( + (event: NativeSyntheticEvent) => { + inputRef.current?.blur(); + + let newUrl = event.nativeEvent.text; + + if (!isValidURL(newUrl)) { + newUrl = GOOGLE_SEARCH_URL + newUrl; + } else if (!newUrl.startsWith(HTTP) && !newUrl.startsWith(HTTPS)) { + newUrl = HTTPS + newUrl; + } + + if (newUrl !== url) { + updateActiveTabState({ url: newUrl }, tabId); + } else { + onRefresh(); + } + }, + [inputRef, onRefresh, tabId, updateActiveTabState, url] + ); + + const onAddressInputPressWorklet = () => { + 'worklet'; + isFocusedValue.value = true; + if (searchViewProgress !== undefined) { + searchViewProgress.value = withSpring(1, SPRING_CONFIGS.snappierSpringConfig); + } + runOnJS(setIsFocused)(true); + dispatchCommand(inputRef, 'focus'); + }; + + const onBlur = useCallback(() => { + if (isFocused) { + setIsFocused(false); + } + if (searchViewProgress !== undefined) { + searchViewProgress.value = withSpring(0, SPRING_CONFIGS.snappierSpringConfig); + } + isFocusedValue.value = false; + }, [isFocused, isFocusedValue, searchViewProgress]); return ( { paddingTop="20px" pointerEvents="box-none" position="absolute" - style={[bottomBarStyle, { zIndex: 10000, opacity: 1 }]} + style={[bottomBarStyle, { height: TAB_BAR_HEIGHT + 88, zIndex: 10000 }]} width={{ custom: deviceWidth }} > { style={[{ alignItems: 'center', flexDirection: 'row', justifyContent: 'center' }, barStyle]} width="full" > - + + - + - + ); diff --git a/src/components/DappBrowser/search/search-results/SearchResult.tsx b/src/components/DappBrowser/search/search-results/SearchResult.tsx index 39899e4d72d..ed573cae096 100644 --- a/src/components/DappBrowser/search/search-results/SearchResult.tsx +++ b/src/components/DappBrowser/search/search-results/SearchResult.tsx @@ -5,7 +5,7 @@ import { ButtonPressAnimation } from '@/components/animations'; export const SearchResult = ({ suggested }: { suggested?: boolean }) => { return ( - + { shadow="24px" width={{ custom: 40 }} height={{ custom: 40 }} - borderRadius={10} + style={{ borderRadius: 10 }} /> diff --git a/src/components/DappBrowser/search/search-results/SearchResults.tsx b/src/components/DappBrowser/search/search-results/SearchResults.tsx index 0f361e53d6a..5dc6a437360 100644 --- a/src/components/DappBrowser/search/search-results/SearchResults.tsx +++ b/src/components/DappBrowser/search/search-results/SearchResults.tsx @@ -1,4 +1,4 @@ -import { Box, Inline, Inset, Stack, Text, TextIcon } from '@/design-system'; +import { Box, Inline, Inset, Stack, Text, TextIcon, globalColors, useColorMode } from '@/design-system'; import React from 'react'; import Animated, { useAnimatedStyle } from 'react-native-reanimated'; import { ButtonPressAnimation } from '../../../animations'; @@ -7,20 +7,28 @@ import { SearchResult } from './SearchResult'; export const SearchResults = () => { const { searchViewProgress, searchInputRef } = useBrowserContext(); + const { isDarkMode } = useColorMode(); + const backgroundStyle = useAnimatedStyle(() => ({ - opacity: searchViewProgress?.value, - pointerEvents: searchViewProgress?.value ? 'box-none' : 'none', + opacity: searchViewProgress?.value || 0, + pointerEvents: searchViewProgress?.value ? 'auto' : 'none', })); return ( - - + + - + 􀐫 @@ -34,10 +42,11 @@ export const SearchResults = () => { width={{ custom: 32 }} borderRadius={32} alignItems="center" + right={{ custom: -8 }} justifyContent="center" onPress={() => searchInputRef?.current?.blur()} > - + 􀆄 @@ -48,7 +57,7 @@ export const SearchResults = () => { - + 􀊫 diff --git a/src/components/DappBrowser/utils.ts b/src/components/DappBrowser/utils.ts new file mode 100644 index 00000000000..84539501533 --- /dev/null +++ b/src/components/DappBrowser/utils.ts @@ -0,0 +1,98 @@ +import { Share } from 'react-native'; +import { WebViewNavigationEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent'; +import { RainbowError, logger } from '@/logger'; +import { HTTP, HTTPS } from './constants'; +import { TabState } from './BrowserContext'; + +// ---------------------------------------------------------------------------- // +// URL validation regex breakdown here: https://mathiasbynens.be/demo/url-regex +// +// This is the @diegoperini version, which is a bit long but accurate +// Details on its validation logic: https://gist.github.com/dperini/729294 +// ---------------------------------------------------------------------------- // +const urlPattern = + /^(?:(?:(?:https?):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$/i; + +export function isValidURL(url: string): boolean { + let urlForValidation = url.trim(); + if (!urlForValidation.startsWith(HTTP) && !urlForValidation.startsWith(HTTPS)) { + urlForValidation = HTTPS + urlForValidation; + } + return urlPattern.test(urlForValidation); +} + +export const generateUniqueId = (): string => { + const timestamp = Date.now().toString(36); + const randomString = Math.random().toString(36).slice(2, 7); + return `${timestamp}${randomString}`; +}; + +export const getNameFromFormattedUrl = (formattedUrl: string): string => { + const parts = formattedUrl.split('.'); + let name; + if (parts.length > 2 && parts[parts.length - 2].length <= 2) { + name = parts[parts.length - 3]; + } else if (parts.length >= 2) { + name = parts[parts.length - 2]; + } else { + return formattedUrl; + } + return name.charAt(0).toUpperCase() + name.slice(1); +}; + +export async function handleShareUrl(url: string): Promise { + try { + await Share.share({ message: url }); + } catch (e: any) { + logger.error(new RainbowError('Error sharing browser URL'), { + message: e.message, + url, + }); + } +} + +// ---------------------------------------------------------------------------- // +// 🔍 Navigation state logger +// +// Useful for observing WebView navigation events +// Add to handleNavigationStateChange in BrowserTab to use +// ---------------------------------------------------------------------------- // +export const navigationStateLogger = (navState: WebViewNavigationEvent, tabIndex: number, tabStates: TabState[]) => { + const emoji = navStateEmojiMap[navState.navigationType]; + const eventName = navStateEventNameMap[navState.navigationType]; + const isLoading = navState.loading ? '🔄 YES' : '🙅‍♂️ NO'; + const didUrlChange = navState.url !== tabStates[tabIndex].url ? '🚨 YES' : '🙅‍♂️ NO'; + + return console.log(` + ──────────────────────────── + ${emoji} NAVIGATION EVENT = ${eventName} + + 🌐 navState URL: ${navState.url} + 📂 tabState URL: ${tabStates[tabIndex].url} + + - URL changed? ${didUrlChange} + - loading? ${isLoading} + - canGoBack? ${navState.canGoBack ? '✅ YES' : '🙅‍♂️ NO'} + ──────────────────────────── +`); +}; + +const navStateEmojiMap = { + click: '👆', + formsubmit: '☑️', + backforward: '⬅️ ➡️', + reload: '🔄', + formresubmit: '☑️☑️', + other: '🤷', + undefined: '🤷🤷', +}; + +const navStateEventNameMap = { + click: 'CLICK', + formsubmit: 'FORM SUBMIT', + backforward: 'BACK FORWARD', + reload: 'RELOAD', + formresubmit: 'FORM RESUBMIT', + other: 'OTHER', + undefined: 'UNDEFINED', +}; diff --git a/src/components/PromoSheet.tsx b/src/components/PromoSheet.tsx index 454a275f328..8c4a93a7c22 100644 --- a/src/components/PromoSheet.tsx +++ b/src/components/PromoSheet.tsx @@ -88,7 +88,6 @@ export function PromoSheet({ const contentHeight = deviceHeight - (!isSmallPhone ? sharedCoolModalTopOffset : 0); return ( - // @ts-ignore { return ( }> - {items.map((item: AddWalletItem) => ( - + {items.map((item: AddWalletItem, index: number) => ( + ))} ); diff --git a/src/components/add-wallet/AddWalletRow.tsx b/src/components/add-wallet/AddWalletRow.tsx index 81d476899cb..307453d8d71 100644 --- a/src/components/add-wallet/AddWalletRow.tsx +++ b/src/components/add-wallet/AddWalletRow.tsx @@ -1,28 +1,14 @@ +import React from 'react'; +import { ImageSourcePropType } from 'react-native'; + import { Box, Stack, Text, useForegroundColor } from '@/design-system'; -import { IS_ANDROID, IS_TEST } from '@/env'; import styled from '@/styled-thing'; -import { useTheme } from '@/theme'; -import React from 'react'; -import { GradientText, Text as RNText } from '../text'; import { Icon } from '../icons'; -import ConditionalWrap from 'conditional-wrap'; import { deviceUtils } from '@/utils'; import { ButtonPressAnimation } from '../animations'; - -const RainbowText = styled(GradientText).attrs(({ theme: { colors } }: any) => ({ - angle: false, - colors: colors.gradients.rainbow, - end: { x: 0, y: 0.5 }, - start: { x: 1, y: 0.5 }, - steps: [0, 0.774321, 1], -}))({}); - -const TextIcon = styled(RNText).attrs({ - size: 29, - weight: 'medium', -})({ - marginVertical: IS_ANDROID ? -10 : 0, -}); +import { ImgixImage } from '../images'; +import { Source } from 'react-native-fast-image'; +import { TextColor } from '@/design-system/color/palettes'; const CaretIcon = styled(Icon).attrs(({ color }: { color: string }) => ({ name: 'caret', @@ -34,7 +20,8 @@ const CaretIcon = styled(Icon).attrs(({ color }: { color: string }) => ({ export type AddWalletItem = { title: string; description: string; - icon: string; + descriptionColor?: TextColor; + icon: string | ImageSourcePropType; iconColor?: string; testID?: string; onPress: () => void; @@ -46,19 +33,17 @@ type AddWalletRowProps = { }; export const AddWalletRow = ({ content, totalHorizontalInset }: AddWalletRowProps) => { - const { colors } = useTheme(); const labelQuaternary = useForegroundColor('labelQuaternary'); - const { title, description, icon, iconColor, testID, onPress } = content; + const { title, description, icon, descriptionColor, testID, onPress } = content; // device width - 2 * total horizontal inset from device boundaries - caret column width (30) const contentWidth = deviceUtils.dimensions.width - 2 * totalHorizontalInset - 30; - const shouldUseRainbowText = !iconColor && !(IS_ANDROID && IS_TEST); + const size = 64; return ( - - {children}} - > - {icon} - - ( - - - {children} - - - )} - > - - {title} - - - + + + + {title} + + {description} diff --git a/src/components/animations/animationConfigs.ts b/src/components/animations/animationConfigs.ts new file mode 100644 index 00000000000..b73e783f0bb --- /dev/null +++ b/src/components/animations/animationConfigs.ts @@ -0,0 +1,35 @@ +import { Easing, WithSpringConfig, WithTimingConfig } from 'react-native-reanimated'; + +function createSpringConfigs>(configs: T): T { + return configs; +} +function createTimingConfigs>(configs: T): T { + return configs; +} + +// /---- 🍎 Spring Animations 🍎 ----/ // +// +const springAnimations = createSpringConfigs({ + browserTabTransition: { dampingRatio: 0.82, duration: 800 }, + keyboardConfig: { damping: 500, mass: 3, stiffness: 1000 }, + snappierSpringConfig: { damping: 42, mass: 0.8, stiffness: 800 }, +}); + +export const SPRING_CONFIGS: Record = springAnimations; +// +// /---- END ----/ // + +// /---- ⏱️ Timing Animations ⏱️ ----/ // +// +const timingAnimations = createTimingConfigs({ + buttonPressConfig: { duration: 160, easing: Easing.bezier(0.25, 0.46, 0.45, 0.94) }, + fadeConfig: { duration: 200, easing: Easing.bezier(0.22, 1, 0.36, 1) }, + fastFadeConfig: { duration: 100, easing: Easing.bezier(0.22, 1, 0.36, 1) }, + slowFadeConfig: { duration: 300, easing: Easing.bezier(0.22, 1, 0.36, 1) }, + slowestFadeConfig: { duration: 500, easing: Easing.bezier(0.22, 1, 0.36, 1) }, + tabPressConfig: { duration: 750, easing: Easing.bezier(0.22, 1, 0.36, 1) }, +}); + +export const TIMING_CONFIGS: Record = timingAnimations; +// +// /---- END ----/ // diff --git a/src/components/asset-list/RecyclerAssetList2/index.tsx b/src/components/asset-list/RecyclerAssetList2/index.tsx index cc27e52e3b7..69fb257fb5c 100644 --- a/src/components/asset-list/RecyclerAssetList2/index.tsx +++ b/src/components/asset-list/RecyclerAssetList2/index.tsx @@ -193,23 +193,6 @@ function NavbarOverlay({ accentColor, position }: { accentColor?: string; positi [handlePressConnectedApps, handlePressQRCode, handlePressSettings] ); - const handlePressMenuItemAndroid = React.useCallback( - (buttonIndex: number) => { - switch (buttonIndex) { - case 0: - handlePressSettings(); - break; - case 1: - handlePressQRCode(); - break; - case 2: - handlePressConnectedApps(); - break; - } - }, - [handlePressConnectedApps, handlePressQRCode, handlePressSettings] - ); - return ( item?.actionTitle)} cancelButtonIndex={menuConfig.menuItems.length - 1} - onPressActionSheet={handlePressMenuItemAndroid} + onPressActionSheet={(buttonIndex: number) => { + handlePressMenuItem({ nativeEvent: { actionKey: menuConfig.menuItems[buttonIndex]?.actionKey } }); + }} > diff --git a/src/components/backup/AddWalletToCloudBackupStep.tsx b/src/components/backup/AddWalletToCloudBackupStep.tsx new file mode 100644 index 00000000000..62f92a99e2f --- /dev/null +++ b/src/components/backup/AddWalletToCloudBackupStep.tsx @@ -0,0 +1,123 @@ +import React, { useCallback } from 'react'; +import { Bleed, Box, Inline, Inset, Separator, Stack, Text } from '@/design-system'; +import * as lang from '@/languages'; +import { ImgixImage } from '../images'; +import WalletsAndBackupIcon from '@/assets/WalletsAndBackup.png'; +import { Source } from 'react-native-fast-image'; +import { cloudPlatform } from '@/utils/platform'; +import { ButtonPressAnimation } from '../animations'; +import Routes from '@/navigation/routesNames'; +import { useNavigation } from '@/navigation'; +import { useWallets } from '@/hooks'; +import { WalletCountPerType, useVisibleWallets } from '@/screens/SettingsSheet/useVisibleWallets'; +import { format } from 'date-fns'; +import { useCreateBackup } from './useCreateBackup'; +import { login } from '@/handlers/cloudBackup'; + +const imageSize = 72; + +export default function AddWalletToCloudBackupStep() { + const { goBack } = useNavigation(); + const { wallets, selectedWallet } = useWallets(); + + const walletTypeCount: WalletCountPerType = { + phrase: 0, + privateKey: 0, + }; + + const { lastBackupDate } = useVisibleWallets({ wallets, walletTypeCount }); + + const { onSubmit } = useCreateBackup({ + walletId: selectedWallet.id, + navigateToRoute: { + route: Routes.SETTINGS_SHEET, + params: { + screen: Routes.SETTINGS_SECTION_BACKUP, + }, + }, + }); + + const potentiallyLoginAndSubmit = useCallback(async () => { + await login(); + return onSubmit({}); + }, [onSubmit]); + + const onMaybeLater = useCallback(() => goBack(), [goBack]); + + return ( + + + + + + {lang.t(lang.l.back_up.cloud.add_wallet_to_cloud_backups)} + + + + + + + + + potentiallyLoginAndSubmit().then(success => success && goBack())}> + + + + + 􀎽{' '} + {lang.t(lang.l.back_up.cloud.back_to_cloud_platform_now, { + cloudPlatform, + })} + + + + + + + + + + + + + + + + {lang.t(lang.l.back_up.cloud.mayber_later)} + + + + + + + + + + + {lastBackupDate && ( + + + + + {lang.t(lang.l.back_up.cloud.latest_backup, { + date: format(lastBackupDate, "M/d/yy 'at' h:mm a"), + })} + + + + + )} + + ); +} diff --git a/src/components/backup/BackupChooseProviderStep.tsx b/src/components/backup/BackupChooseProviderStep.tsx new file mode 100644 index 00000000000..da9520335df --- /dev/null +++ b/src/components/backup/BackupChooseProviderStep.tsx @@ -0,0 +1,216 @@ +import React from 'react'; +import { useCreateBackup } from '@/components/backup/useCreateBackup'; +import { Bleed, Box, Inline, Inset, Separator, Stack, Text } from '@/design-system'; +import * as lang from '@/languages'; +import { ImgixImage } from '../images'; +import WalletsAndBackupIcon from '@/assets/WalletsAndBackup.png'; +import ManuallyBackedUpIcon from '@/assets/ManuallyBackedUp.png'; +import Caret from '@/assets/family-dropdown-arrow.png'; +import { Source } from 'react-native-fast-image'; +import { cloudPlatform } from '@/utils/platform'; +import { useTheme } from '@/theme'; +import { ButtonPressAnimation } from '../animations'; +import { useNavigation } from '@/navigation'; +import Routes from '@/navigation/routesNames'; +import { SETTINGS_BACKUP_ROUTES } from '@/screens/SettingsSheet/components/Backups/routes'; +import { useWallets } from '@/hooks'; +import walletTypes from '@/helpers/walletTypes'; +import walletBackupTypes from '@/helpers/walletBackupTypes'; +import { IS_ANDROID } from '@/env'; +import { GoogleDriveUserData, getGoogleAccountUserData, isCloudBackupAvailable, login } from '@/handlers/cloudBackup'; +import { WrappedAlert as Alert } from '@/helpers/alert'; +import { RainbowError, logger } from '@/logger'; +import { Linking } from 'react-native'; + +const imageSize = 72; + +export default function BackupSheetSectionNoProvider() { + const { colors } = useTheme(); + const { navigate, goBack } = useNavigation(); + const { selectedWallet } = useWallets(); + + const { onSubmit, loading } = useCreateBackup({ + walletId: selectedWallet.id, + navigateToRoute: { + route: Routes.SETTINGS_SHEET, + params: { + screen: Routes.SETTINGS_SECTION_BACKUP, + }, + }, + }); + + const onCloudBackup = async () => { + if (loading !== 'none') { + return; + } + // NOTE: On Android we need to make sure the user is signed into a Google account before trying to backup + // otherwise we'll fake backup and it's confusing... + if (IS_ANDROID) { + try { + await login(); + getGoogleAccountUserData().then((accountDetails: GoogleDriveUserData | undefined) => { + if (!accountDetails) { + Alert.alert(lang.t(lang.l.back_up.errors.no_account_found)); + return; + } + }); + } catch (e) { + Alert.alert(lang.t(lang.l.back_up.errors.no_account_found)); + logger.error(e as RainbowError); + } + } else { + const isAvailable = await isCloudBackupAvailable(); + if (!isAvailable) { + Alert.alert( + lang.t(lang.l.modal.back_up.alerts.cloud_not_enabled.label), + lang.t(lang.l.modal.back_up.alerts.cloud_not_enabled.description), + [ + { + onPress: () => { + Linking.openURL('https://support.apple.com/en-us/HT204025'); + }, + text: lang.t(lang.l.modal.back_up.alerts.cloud_not_enabled.show_me), + }, + { + style: 'cancel', + text: lang.t(lang.l.modal.back_up.alerts.cloud_not_enabled.no_thanks), + }, + ] + ); + return; + } + } + + onSubmit({}); + }; + + const onManualBackup = async () => { + const title = + selectedWallet?.imported && selectedWallet.type === walletTypes.privateKey ? selectedWallet.addresses[0].label : selectedWallet.name; + + goBack(); + navigate(Routes.SETTINGS_SHEET, { + screen: SETTINGS_BACKUP_ROUTES.SECRET_WARNING, + params: { + isBackingUp: true, + title, + backupType: walletBackupTypes.manual, + walletId: selectedWallet.id, + }, + }); + }; + + return ( + + + + {lang.t(lang.l.back_up.cloud.how_would_you_like_to_backup)} + + + + + + + + {/* replace this with BackUpMenuButton */} + + + + + + + + + + + {lang.t(lang.l.back_up.cloud.cloud_backup)} + + + + {lang.t(lang.l.back_up.cloud.recommended_for_beginners)} + {' '} + {lang.t(lang.l.back_up.cloud.choose_backup_cloud_description, { + cloudPlatform, + })} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {lang.t(lang.l.back_up.cloud.manual_backup)} + + + {lang.t(lang.l.back_up.cloud.choose_backup_manual_description)} + + + + + + + + + + + + + + ); +} diff --git a/src/components/backup/BackupCloudStep.js b/src/components/backup/BackupCloudStep.js deleted file mode 100644 index 1b5a94c3145..00000000000 --- a/src/components/backup/BackupCloudStep.js +++ /dev/null @@ -1,307 +0,0 @@ -import { useRoute } from '@react-navigation/native'; -import { captureMessage } from '@sentry/react-native'; -import * as lang from '@/languages'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { InteractionManager, Keyboard } from 'react-native'; -import { passwordStrength } from 'check-password-strength'; -import { isSamsungGalaxy } from '../../helpers/samsung'; -import { saveBackupPassword } from '../../model/backup'; -import { cloudPlatform } from '../../utils/platform'; -import { DelayedAlert } from '../alerts'; -import { PasswordField } from '../fields'; -import { Centered, ColumnWithMargins } from '../layout'; -import { GradientText, Text } from '../text'; -import BackupSheetKeyboardLayout from './BackupSheetKeyboardLayout'; -import { analytics } from '@/analytics'; -import { cloudBackupPasswordMinLength, isCloudBackupPasswordValid } from '@/handlers/cloudBackup'; -import showWalletErrorAlert from '@/helpers/support'; -import { useDimensions, useMagicAutofocus, useRouteExistsInNavigationState, useWalletCloudBackup, useWallets } from '@/hooks'; -import { useNavigation } from '@/navigation'; -import Routes from '@/navigation/routesNames'; -import styled from '@/styled-thing'; -import { padding } from '@/styles'; -import logger from '@/utils/logger'; - -const DescriptionText = styled(Text).attrs(({ isTinyPhone, theme: { colors } }) => ({ - align: 'center', - color: colors.alpha(colors.blueGreyDark, 0.5), - lineHeight: 'looser', - size: isTinyPhone ? 'lmedium' : 'large', -}))({}); - -const ImportantText = styled(DescriptionText).attrs(({ theme: { colors } }) => ({ - color: colors.alpha(colors.blueGreyDark, 0.6), - weight: 'medium', -}))({}); - -const Masthead = styled(Centered).attrs({ - direction: 'column', -})(({ isTallPhone, isTinyPhone }) => ({ - ...padding.object(isTinyPhone ? 0 : 9, isTinyPhone ? 10 : 50, isTallPhone ? 39 : 19), - flexShrink: 0, -})); - -const MastheadIcon = styled(GradientText).attrs(({ theme: { colors } }) => ({ - align: 'center', - angle: false, - colors: colors.gradients.rainbow, - end: { x: 0, y: 0.5 }, - size: 43, - start: { x: 1, y: 0.5 }, - steps: [0, 0.774321, 1], - weight: 'medium', -}))({}); - -const Title = styled(Text).attrs(({ isTinyPhone }) => ({ - size: isTinyPhone ? 'large' : 'big', - weight: 'bold', -}))(({ isTinyPhone }) => ({ - ...(isTinyPhone ? padding.object(0) : padding.object(15, 0, 12)), -})); - -const samsungGalaxy = (android && isSamsungGalaxy()) || false; - -export default function BackupCloudStep() { - const { isTallPhone, isTinyPhone } = useDimensions(); - const currentlyFocusedInput = useRef(); - const { goBack } = useNavigation(); - const { params } = useRoute(); - const walletCloudBackup = useWalletCloudBackup(); - const { selectedWallet, isDamaged } = useWallets(); - const [validPassword, setValidPassword] = useState(false); - const [isKeyboardOpen, setIsKeyboardOpen] = useState(false); - const [passwordFocused, setPasswordFocused] = useState(true); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); - const { navigate } = useNavigation(); - const keyboardShowListener = useRef(null); - const keyboardHideListener = useRef(null); - - useEffect(() => { - const keyboardDidShow = () => { - setIsKeyboardOpen(true); - }; - - const keyboardDidHide = () => { - setIsKeyboardOpen(false); - }; - keyboardShowListener.current = Keyboard.addListener('keyboardDidShow', keyboardDidShow); - keyboardHideListener.current = Keyboard.addListener('keyboardDidHide', keyboardDidHide); - if (isDamaged) { - showWalletErrorAlert(); - captureMessage('Damaged wallet preventing cloud backup'); - goBack(); - } - return () => { - keyboardShowListener.current?.remove(); - keyboardHideListener.current?.remove(); - }; - }, [goBack, isDamaged]); - - const isSettingsRoute = useRouteExistsInNavigationState(Routes.SETTINGS_SHEET); - - const walletId = params?.walletId || selectedWallet.id; - - const [label, setLabel] = useState( - !validPassword - ? `􀙶 ${lang.t(lang.l.back_up.confirm_password.add_to_cloud_platform, { - cloudPlatformName: cloudPlatform, - })}` - : `􀎽 ${lang.t(lang.l.back_up.confirm_password.confirm_backup)}` - ); - const passwordRef = useRef(); - const confirmPasswordRef = useRef(); - - useEffect(() => { - setTimeout(() => { - passwordRef.current?.focus(); - }, 1); - analytics.track('Choose Password Step', { - category: 'backup', - label: cloudPlatform, - }); - }, []); - - const { handleFocus } = useMagicAutofocus(passwordRef); - - const onPasswordFocus = useCallback( - target => { - handleFocus(target); - setPasswordFocused(true); - currentlyFocusedInput.current = passwordRef.current; - }, - [handleFocus] - ); - - const onConfirmPasswordFocus = useCallback( - target => { - handleFocus(target); - currentlyFocusedInput.current = confirmPasswordRef.current; - }, - [handleFocus] - ); - - const onPasswordBlur = useCallback(() => { - setPasswordFocused(false); - }, []); - - const onPasswordSubmit = useCallback(() => { - confirmPasswordRef.current?.focus(); - }, []); - - useEffect(() => { - let passwordIsValid = false; - if (password === confirmPassword && isCloudBackupPasswordValid(password)) { - passwordIsValid = true; - } - - let newLabel = ''; - if (passwordIsValid) { - newLabel = `􀎽 ${lang.t(lang.l.back_up.cloud.password.confirm_backup)}`; - } else if (password.length < cloudBackupPasswordMinLength) { - newLabel = lang.t('back_up.cloud.password.minimum_characters', { - minimumLength: cloudBackupPasswordMinLength, - }); - } else if ( - // TODO FIXME This branch of the if/else will never execute - // eslint-disable-next-line no-dupe-else-if - password !== '' && - password.length < cloudBackupPasswordMinLength && - !passwordRef.current?.isFocused() - ) { - newLabel = lang.t(lang.l.back_up.cloud.password.use_a_longer_password); - } else if ( - isCloudBackupPasswordValid(password) && - isCloudBackupPasswordValid(confirmPassword) && - confirmPassword.length >= password.length && - password !== confirmPassword - ) { - newLabel = lang.t(lang.l.back_up.cloud.password.passwords_dont_match); - } else if (password.length >= cloudBackupPasswordMinLength && !passwordFocused) { - newLabel = lang.t(lang.l.back_up.cloud.password.confirm_password); - } else if (password.length >= cloudBackupPasswordMinLength && passwordFocused) { - const passInfo = passwordStrength(password); - switch (passInfo.id) { - case 0: - case 1: - newLabel = `💩 ${lang.t(lang.l.back_up.cloud.password.strength.level1)}`; - break; - case 2: - newLabel = `👌 ${lang.t(lang.l.back_up.cloud.password.strength.level2)}`; - break; - case 3: - newLabel = `💪 ${lang.t(lang.l.back_up.cloud.password.strength.level3)}`; - break; - case 4: - newLabel = `🏰️ ${lang.t(lang.l.back_up.cloud.password.strength.level4)}`; - break; - default: - } - } - - setValidPassword(passwordIsValid); - setLabel(newLabel); - }, [confirmPassword, password, passwordFocused]); - - const onPasswordChange = useCallback(({ nativeEvent: { text: inputText } }) => { - setPassword(inputText); - }, []); - - const onConfirmPasswordChange = useCallback(({ nativeEvent: { text: inputText } }) => { - setConfirmPassword(inputText); - }, []); - - const onError = useCallback( - msg => { - setTimeout(onPasswordSubmit, 1000); - DelayedAlert({ title: msg }, 500); - }, - [onPasswordSubmit] - ); - - const onSuccess = useCallback(async () => { - logger.log('BackupCloudStep:: saving backup password'); - await saveBackupPassword(password); - if (!isSettingsRoute) { - DelayedAlert({ title: lang.t(lang.l.cloud.backup_success) }, 1000); - } - // This means the user set a new password - // and it was the first wallet backed up - analytics.track('Backup Complete', { - category: 'backup', - label: cloudPlatform, - }); - goBack(); - }, [goBack, isSettingsRoute, password]); - - const onConfirmBackup = useCallback(async () => { - analytics.track('Tapped "Confirm Backup"'); - - await walletCloudBackup({ - onError, - onSuccess, - password, - walletId, - }); - }, [onError, onSuccess, password, walletCloudBackup, walletId]); - - const showExplainerConfirmation = useCallback(async () => { - android && Keyboard.dismiss(); - navigate(Routes.EXPLAIN_SHEET, { - onClose: () => { - InteractionManager.runAfterInteractions(() => { - setTimeout(() => { - onConfirmBackup(); - }, 300); - }); - }, - type: 'backup', - }); - }, [navigate, onConfirmBackup]); - - const onConfirmPasswordSubmit = useCallback(() => { - validPassword && showExplainerConfirmation(); - }, [showExplainerConfirmation, validPassword]); - - return ( - - - {(isTinyPhone || samsungGalaxy) && isKeyboardOpen ? null : 􀌍} - {lang.t(lang.l.back_up.cloud.password.choose_a_password)} - - {lang.t(lang.l.back_up.cloud.password.a_password_youll_remember)} -   - {lang.t(lang.l.back_up.cloud.password.it_cant_be_recovered)} - - - - - = password.length && confirmPassword !== password - } - isValid={validPassword} - onChange={onConfirmPasswordChange} - onFocus={onConfirmPasswordFocus} - onSubmitEditing={onConfirmPasswordSubmit} - password={confirmPassword} - placeholder={lang.t(lang.l.back_up.cloud.password.confirm_placeholder)} - ref={confirmPasswordRef} - /> - - - ); -} diff --git a/src/components/backup/BackupCloudStep.tsx b/src/components/backup/BackupCloudStep.tsx new file mode 100644 index 00000000000..e839d9323c0 --- /dev/null +++ b/src/components/backup/BackupCloudStep.tsx @@ -0,0 +1,256 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { RouteProp, useRoute } from '@react-navigation/native'; +import { Source } from 'react-native-fast-image'; +import { KeyboardArea } from 'react-native-keyboard-area'; + +import * as lang from '@/languages'; +import { sharedCoolModalTopOffset } from '@/navigation/config'; +import { cloudPlatform } from '@/utils/platform'; +import { PasswordField } from '@/components/fields'; +import { Text } from '@/components/text'; +import WalletAndBackup from '@/assets/WalletsAndBackup.png'; +import { analytics } from '@/analytics'; +import { cloudBackupPasswordMinLength, isCloudBackupPasswordValid } from '@/handlers/cloudBackup'; +import { useDimensions, useMagicAutofocus, useWallets } from '@/hooks'; +import styled from '@/styled-thing'; +import { padding } from '@/styles'; +import { Box, Inset, Stack } from '@/design-system'; +import { ImgixImage } from '../images'; +import { IS_ANDROID } from '@/env'; +import { RainbowButton } from '../buttons'; +import RainbowButtonTypes from '../buttons/rainbow-button/RainbowButtonTypes'; +import { usePasswordValidation } from './usePasswordValidation'; +import { TextInput } from 'react-native'; +import { useTheme } from '@/theme'; +import { useNavigation } from '@/navigation'; +import Routes from '@/navigation/routesNames'; +import { SETTINGS_BACKUP_ROUTES } from '@/screens/SettingsSheet/components/Backups/routes'; +import walletTypes from '@/helpers/walletTypes'; + +type BackupCloudStepParams = { + BackupCloudStep: { + isFromWalletReadyPrompt?: boolean; + walletId?: string; + onSuccess: (password: string) => Promise; + onCancel: () => Promise; + }; +}; + +type NativeEvent = { + nativeEvent: { + text: string; + }; +}; + +export function BackupCloudStep() { + const { isDarkMode } = useTheme(); + const { goBack } = useNavigation(); + const { width: deviceWidth, height: deviceHeight } = useDimensions(); + const { params } = useRoute>(); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + const { onSuccess, onCancel, isFromWalletReadyPrompt = false } = params; + + const { validPassword, label, labelColor } = usePasswordValidation(password, confirmPassword); + + const currentlyFocusedInput = useRef(null); + const passwordRef = useRef(null); + const confirmPasswordRef = useRef(null); + + useEffect(() => { + setTimeout(() => { + passwordRef.current?.focus(); + }, 1); + analytics.track('Choose Password Step', { + category: 'backup', + label: cloudPlatform, + }); + }, []); + + const { handleFocus } = useMagicAutofocus(passwordRef); + + const onTextInputFocus = useCallback( + (target: any, isConfirm = false) => { + const ref = isConfirm ? confirmPasswordRef.current : passwordRef.current; + handleFocus(target); + currentlyFocusedInput.current = ref; + }, + [handleFocus] + ); + + const onPasswordSubmit = useCallback(() => { + confirmPasswordRef.current?.focus(); + }, []); + + const onPasswordChange = useCallback(({ nativeEvent: { text: inputText } }: NativeEvent) => { + setPassword(inputText); + setConfirmPassword(''); + }, []); + + const onConfirmPasswordChange = useCallback(({ nativeEvent: { text: inputText } }: NativeEvent) => { + setConfirmPassword(inputText); + }, []); + + const onSuccessAndNavigateBack = useCallback( + async (password: string) => { + if (!isFromWalletReadyPrompt) { + goBack(); + } + + onSuccess(password); + }, + [goBack, isFromWalletReadyPrompt, onSuccess] + ); + + useEffect(() => { + return () => { + if (!password) { + onCancel(); + } + }; + }, []); + + return ( + + + + + + + {lang.t(lang.l.back_up.cloud.password.choose_a_password)} + + {lang.t(lang.l.back_up.cloud.password.a_password_youll_remember_part_one)} +   + {lang.t(lang.l.back_up.cloud.password.not)} +   + {lang.t(lang.l.back_up.cloud.password.a_password_youll_remember_part_two)} + + + + + onTextInputFocus(target)} + onSubmitEditing={onPasswordSubmit} + password={password} + placeholder={lang.t(lang.l.back_up.cloud.password.backup_password)} + ref={passwordRef} + returnKeyType="next" + textContentType="newPassword" + /> + {isCloudBackupPasswordValid(password) && ( + = password.length && confirmPassword !== password + } + isValid={validPassword} + onChange={onConfirmPasswordChange} + onFocus={(target: any) => onTextInputFocus(target, true)} + onSubmitEditing={() => onSuccessAndNavigateBack(password)} + password={confirmPassword} + placeholder={lang.t(lang.l.back_up.cloud.password.confirm_placeholder)} + ref={confirmPasswordRef} + /> + )} + + {label} + + + + + {validPassword && ( + onSuccessAndNavigateBack(password)} + /> + )} + + {!validPassword && ( + + + {`􀎽 ${lang.t(lang.l.back_up.cloud.back_up_to_platform, { + cloudPlatformName: cloudPlatform, + })}`} + + + )} + + {IS_ANDROID ? : null} + + + + ); +} + +export default BackupCloudStep; + +const DescriptionText = styled(Text).attrs(({ theme: { colors }, color }: any) => ({ + align: 'left', + color: color || colors.alpha(colors.blueGreyDark, 0.5), + lineHeight: 'looser', + size: 'lmedium', + weight: 'medium', +}))({}); + +const KeyboardSizeView = styled(KeyboardArea)({ + backgroundColor: ({ theme: { colors } }: any) => colors.transparent, +}); + +const ImportantText = styled(DescriptionText).attrs(({ theme: { colors } }: any) => ({ + color: colors.red, + weight: 'bold', +}))({}); + +const Masthead = styled(Box).attrs({ + direction: 'column', +})({ + ...padding.object(0, 0, 16), + gap: 8, + flexShrink: 0, +}); + +const Title = styled(Text).attrs({ + size: 'big', + weight: 'heavy', +})({ + ...padding.object(12, 0, 0), +}); + +const ButtonText = styled(Text).attrs(({ theme: { colors }, color }: any) => ({ + align: 'center', + letterSpacing: 'rounded', + color: color || colors.alpha(colors.blueGreyDark, 0.5), + size: 'larger', + weight: 'heavy', + numberOfLines: 1, +}))({}); diff --git a/src/components/backup/BackupConfirmPasswordStep.js b/src/components/backup/BackupConfirmPasswordStep.js deleted file mode 100644 index e8d9cc9a5d3..00000000000 --- a/src/components/backup/BackupConfirmPasswordStep.js +++ /dev/null @@ -1,175 +0,0 @@ -import { useRoute } from '@react-navigation/native'; -import lang from 'i18n-js'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Keyboard } from 'react-native'; -import { isSamsungGalaxy } from '../../helpers/samsung'; -import { saveBackupPassword } from '../../model/backup'; -import { cloudPlatform } from '../../utils/platform'; -import { DelayedAlert } from '../alerts'; -import { PasswordField } from '../fields'; -import { Centered, Column } from '../layout'; -import { GradientText, Text } from '../text'; -import BackupSheetKeyboardLayout from './BackupSheetKeyboardLayout'; -import { analytics } from '@/analytics'; -import { cloudBackupPasswordMinLength, isCloudBackupPasswordValid } from '@/handlers/cloudBackup'; -import { useBooleanState, useDimensions, useRouteExistsInNavigationState, useWalletCloudBackup, useWallets } from '@/hooks'; -import { useNavigation } from '@/navigation'; -import Routes from '@/navigation/routesNames'; -import styled from '@/styled-thing'; -import { margin, padding } from '@/styles'; -import logger from '@/utils/logger'; - -const DescriptionText = styled(Text).attrs(({ theme: { colors } }) => ({ - align: 'center', - color: colors.alpha(colors.blueGreyDark, 0.5), - lineHeight: 'looser', - size: 'large', -}))({ - ...padding.object(0, 50), -}); - -const Masthead = styled(Centered).attrs({ - direction: 'column', -})({ - ...padding.object(24, 0, 42), - flexShrink: 0, -}); - -const MastheadIcon = styled(GradientText).attrs({ - align: 'center', - angle: false, - colors: ['#FFB114', '#FF54BB', '#00F0FF'], - end: { x: 0, y: 0 }, - letterSpacing: 'roundedTight', - size: 52, - start: { x: 1, y: 1 }, - steps: [0, 0.5, 1], - weight: 'bold', -})({}); - -const Title = styled(Text).attrs({ - size: 'big', - weight: 'bold', -})({ - ...margin.object(15, 0, 12), -}); - -const samsungGalaxy = (android && isSamsungGalaxy()) || false; - -export default function BackupConfirmPasswordStep() { - const { isTinyPhone } = useDimensions(); - const { params } = useRoute(); - const { goBack } = useNavigation(); - const walletCloudBackup = useWalletCloudBackup(); - const [isKeyboardOpen, setIsKeyboardOpen] = useState(false); - const [validPassword, setValidPassword] = useState(false); - const [passwordFocused, setPasswordFocused, setPasswordBlurred] = useBooleanState(true); - const [password, setPassword] = useState(''); - const [label, setLabel] = useState(`􀎽 ${lang.t('back_up.confirm_password.confirm_backup')}`); - const passwordRef = useRef(); - const keyboardShowListener = useRef(null); - const keyboardHideListener = useRef(null); - const { selectedWallet } = useWallets(); - const walletId = params?.walletId || selectedWallet.id; - - const isSettingsRoute = useRouteExistsInNavigationState(Routes.SETTINGS_SHEET); - - useEffect(() => { - const keyboardDidShow = () => { - setIsKeyboardOpen(true); - }; - - const keyboardDidHide = () => { - setIsKeyboardOpen(false); - }; - keyboardShowListener.current = Keyboard.addListener('keyboardDidShow', keyboardDidShow); - keyboardHideListener.current = Keyboard.addListener('keyboardDidHide', keyboardDidHide); - return () => { - keyboardShowListener.current?.remove(); - keyboardHideListener.current?.remove(); - }; - }, []); - - useEffect(() => { - analytics.track('Confirm Password Step', { - category: 'backup', - label: cloudPlatform, - }); - }, []); - - useEffect(() => { - let passwordIsValid = false; - - if (isCloudBackupPasswordValid(password)) { - passwordIsValid = true; - setLabel( - `􀑙 ${lang.t('back_up.confirm_password.add_to_cloud_platform', { - cloudPlatformName: cloudPlatform, - })}` - ); - } - setValidPassword(passwordIsValid); - }, [password, passwordFocused]); - - const onPasswordChange = useCallback(({ nativeEvent: { text: inputText } }) => { - setPassword(inputText); - }, []); - - const onError = useCallback(msg => { - passwordRef.current?.focus(); - DelayedAlert({ title: msg }, 500); - }, []); - - const onSuccess = useCallback(async () => { - logger.log('BackupConfirmPasswordStep:: saving backup password'); - await saveBackupPassword(password); - if (!isSettingsRoute) { - DelayedAlert({ title: lang.t('cloud.backup_success') }, 1000); - } - // This means the user didn't have the password saved - // and at least an other wallet already backed up - analytics.track('Backup Complete via Confirm Step', { - category: 'backup', - label: cloudPlatform, - }); - goBack(); - }, [goBack, isSettingsRoute, password]); - - const onSubmit = useCallback(async () => { - if (!validPassword) return; - analytics.track('Tapped "Restore from cloud"'); - await walletCloudBackup({ - onError, - onSuccess, - password, - walletId, - }); - }, [onError, onSuccess, password, validPassword, walletCloudBackup, walletId]); - - return ( - - - {(isTinyPhone || samsungGalaxy) && isKeyboardOpen ? null : 􀙶} - {lang.t('back_up.confirm_password.enter_backup_password')} - - {lang.t('back_up.confirm_password.enter_backup_description', { - cloudPlatformName: cloudPlatform, - })} - - - - - - - ); -} diff --git a/src/components/backup/BackupManualStep.js b/src/components/backup/BackupManualStep.js deleted file mode 100644 index 8aecf52cd31..00000000000 --- a/src/components/backup/BackupManualStep.js +++ /dev/null @@ -1,137 +0,0 @@ -import { useRoute } from '@react-navigation/native'; -import lang from 'i18n-js'; -import React, { Fragment, useCallback, useEffect, useState } from 'react'; -import { View } from 'react-native'; -import { useTheme } from '../../theme/ThemeContext'; -import { Column, Row } from '../layout'; -import { SheetActionButton } from '../sheet'; -import { Nbsp, Text } from '../text'; -import { analytics } from '@/analytics'; -import WalletTypes from '@/helpers/walletTypes'; -import { useDimensions, useWalletManualBackup, useWallets } from '@/hooks'; -import { useNavigation } from '@/navigation'; -import styled from '@/styled-thing'; -import { padding } from '@/styles'; -import { SecretDisplaySection } from '@/components/secret-display/SecretDisplaySection'; - -const Content = styled(Column).attrs({ - align: 'center', - justify: 'start', -})({ - flexGrow: 1, - flexShrink: 0, - paddingTop: ({ isTallPhone, isSmallPhone }) => (android ? 30 : isTallPhone ? 45 : isSmallPhone ? 10 : 25), -}); - -const Footer = styled(Column).attrs({ - justify: 'center', -})({ - ...padding.object(0, 15, 21), - - marginBottom: android ? 30 : 0, - width: '100%', -}); - -const Masthead = styled(Column).attrs({ - align: 'center', - justify: 'start', -})({}); - -const MastheadDescription = styled(Text).attrs(({ theme: { colors } }) => ({ - align: 'center', - color: colors.alpha(colors.blueGreyDark, 0.6), - lineHeight: 'looser', - size: 'lmedium', -}))({ - maxWidth: 291, -}); - -const MastheadIcon = styled(Text).attrs({ - align: 'center', - color: 'appleBlue', - size: 21, - weight: 'heavy', -})({}); - -const MastheadTitle = styled(Text).attrs({ - align: 'center', - size: 'larger', - weight: 'bold', -})({ - ...padding.object(8), -}); - -const MastheadTitleRow = styled(Row).attrs({ - align: 'center', - justify: 'start', -})({ - paddingTop: 18, -}); - -export default function BackupManualStep() { - const { isTallPhone, isSmallPhone } = useDimensions(); - const { goBack } = useNavigation(); - const { selectedWallet } = useWallets(); - const { onManuallyBackupWalletId } = useWalletManualBackup(); - const { params } = useRoute(); - const walletId = params?.walletId || selectedWallet.id; - const { colors } = useTheme(); - - const [type, setType] = useState(null); - const [secretLoaded, setSecretLoaded] = useState(false); - - const onComplete = useCallback(() => { - analytics.track(`Tapped "I've saved the secret"`, { - type, - }); - onManuallyBackupWalletId(walletId); - analytics.track('Backup Complete', { - category: 'backup', - label: 'manual', - }); - goBack(); - }, [goBack, onManuallyBackupWalletId, type, walletId]); - - useEffect(() => { - analytics.track('Manual Backup Step', { - category: 'backup', - label: 'manual', - }); - }, []); - - return ( - - - - 􀉆 - {lang.t('back_up.manual.label')} - - - - {type === WalletTypes.privateKey ? lang.t('back_up.manual.pkey.these_keys') : lang.t('back_up.manual.seed.these_keys')} - - - {type === WalletTypes.privateKey ? lang.t('back_up.manual.pkey.save_them') : lang.t('back_up.manual.seed.save_them')} - - - - - -
- {secretLoaded && ( - - - - )} -
-
- ); -} diff --git a/src/components/backup/BackupManuallyStep.tsx b/src/components/backup/BackupManuallyStep.tsx new file mode 100644 index 00000000000..211eda2d196 --- /dev/null +++ b/src/components/backup/BackupManuallyStep.tsx @@ -0,0 +1,98 @@ +import React, { useCallback } from 'react'; +import { Bleed, Box, Inline, Inset, Separator, Stack, Text } from '@/design-system'; +import * as lang from '@/languages'; +import { ImgixImage } from '../images'; +import ManuallyBackedUpIcon from '@/assets/ManuallyBackedUp.png'; +import { Source } from 'react-native-fast-image'; +import { ButtonPressAnimation } from '../animations'; +import { useNavigation } from '@/navigation'; +import Routes from '@/navigation/routesNames'; +import { useWallets } from '@/hooks'; +import walletTypes from '@/helpers/walletTypes'; +import { SETTINGS_BACKUP_ROUTES } from '@/screens/SettingsSheet/components/Backups/routes'; +import walletBackupTypes from '@/helpers/walletBackupTypes'; + +const imageSize = 72; + +export default function BackupManuallyStep() { + const { navigate, goBack } = useNavigation(); + const { selectedWallet } = useWallets(); + + const onManualBackup = async () => { + const title = + selectedWallet?.imported && selectedWallet.type === walletTypes.privateKey ? selectedWallet.addresses[0].label : selectedWallet.name; + + goBack(); + navigate(Routes.SETTINGS_SHEET, { + screen: SETTINGS_BACKUP_ROUTES.SECRET_WARNING, + params: { + isBackingUp: true, + title, + backupType: walletBackupTypes.manual, + walletId: selectedWallet.id, + }, + }); + }; + + const onMaybeLater = useCallback(() => goBack(), [goBack]); + + return ( + + + + + + {lang.t(lang.l.back_up.manual.backup_manually_now)} + + + + + + + + + + + + + + {lang.t(lang.l.back_up.manual.back_up_now)} + + + + + + + + + + + + + + + + {lang.t(lang.l.back_up.manual.already_backed_up)} + + + + + + + + + + + ); +} diff --git a/src/components/backup/BackupRainbowButton.tsx b/src/components/backup/BackupRainbowButton.tsx new file mode 100644 index 00000000000..614153a2767 --- /dev/null +++ b/src/components/backup/BackupRainbowButton.tsx @@ -0,0 +1,121 @@ +import MaskedView from '@react-native-masked-view/masked-view'; +import React from 'react'; +import { useTheme } from '@/theme'; +import { ButtonPressAnimation } from '@/components/animations'; +import { RowWithMargins } from '@/components/layout'; +import { Text } from '@/components/text'; +import RainbowButtonTypes from '@/components/buttons/rainbow-button/RainbowButtonTypes'; +import { useDimensions } from '@/hooks'; +import styled from '@/styled-thing'; +import { shadow } from '@/styles'; +import ShadowView from '@/react-native-shadow-stack/ShadowView'; +import BackupRainbowButtonBackground from './BackupRainbowButtonBackground'; +import { View } from 'react-native'; + +const ButtonContainer = styled(MaskedView).attrs({ + pointerEvents: 'none', +})(({ width, height }: any) => ({ + height, + width, +})); + +const ButtonContent = styled(RowWithMargins).attrs({ + align: 'center', + margin: -2.5, +})({ + alignSelf: 'center', + bottom: 2, + height: '100%', +}); + +const ButtonLabel = styled(Text).attrs(({ disabled, type, theme: { colors, isDarkMode } }: any) => ({ + align: type === RainbowButtonTypes.addCash ? 'left' : 'center', + color: isDarkMode && disabled ? colors.white : colors.whiteLabel, + letterSpacing: type === RainbowButtonTypes.addCash ? 'roundedTight' : 'rounded', + size: type === RainbowButtonTypes.small ? 'large' : 'larger', + weight: type === RainbowButtonTypes.small ? 'bold' : 'heavy', + numberOfLines: 1, +}))({}); + +const OuterButton = styled(View)(({ height, width, isDarkMode, disabled, strokeWidth, theme: { colors } }: any) => ({ + ...shadow.buildAsObject(0, 5, 15, colors.shadow), + backgroundColor: colors.dark, + borderRadius: height / 2 + strokeWidth, + height, + shadowOpacity: isDarkMode && disabled ? 0 : isDarkMode ? 0.1 : 0.4, + width, +})); + +const Shadow = styled(ShadowView)(({ height, strokeWidth, isDarkMode, disabled, width, theme: { colors } }: any) => ({ + ...shadow.buildAsObject(0, 10, 30, colors.shadow, 1), + backgroundColor: colors.white, + borderRadius: height / 2 + strokeWidth, + height, + opacity: isDarkMode && disabled ? 0 : android ? 1 : 0.2, + position: 'absolute', + width, +})); + +type BackupRainbowButtonProps = { + disabled?: boolean; + height?: number; + label?: string; + onPress?: () => void; + strokeWidth?: number; + width?: number; + overflowMargin?: number; + skipTopMargin?: boolean; +}; + +const BackupRainbowButton = ({ + disabled = false, + height = 56, + label = 'Press me', + onPress, + strokeWidth = 1, + width, + overflowMargin = 35, + skipTopMargin = true, + ...props +}: BackupRainbowButtonProps) => { + const { isDarkMode } = useTheme(); + + const { width: deviceWidth } = useDimensions(); + const maxButtonWidth = deviceWidth - 30; + + const btnStrokeWidth = disabled ? 0.5 : strokeWidth; + const btnWidth = width || maxButtonWidth; + + const outerButtonMask = ( + + ); + + return ( + + + + + + + {label} + + + + + ); +}; + +export default BackupRainbowButton; diff --git a/src/components/buttons/rainbow-button/RainbowButtonBackground.js b/src/components/backup/BackupRainbowButtonBackground.tsx similarity index 73% rename from src/components/buttons/rainbow-button/RainbowButtonBackground.js rename to src/components/backup/BackupRainbowButtonBackground.tsx index 7b415585ae7..75059a18ecc 100644 --- a/src/components/buttons/rainbow-button/RainbowButtonBackground.js +++ b/src/components/backup/BackupRainbowButtonBackground.tsx @@ -1,25 +1,28 @@ +/* eslint-disable no-nested-ternary */ import MaskedView from '@react-native-masked-view/masked-view'; import React from 'react'; import { View } from 'react-native'; import RadialGradient from 'react-native-radial-gradient'; -import { darkModeThemeColors } from '../../../styles/colors'; -import { useTheme } from '../../../theme/ThemeContext'; -import RainbowButtonTypes from './RainbowButtonTypes'; +import { darkModeThemeColors } from '@/styles/colors'; +import { useTheme } from '@/theme'; +import RainbowButtonTypes from '@/components/buttons/rainbow-button/RainbowButtonTypes'; import styled from '@/styled-thing'; import { margin } from '@/styles'; import { magicMemo } from '@/utils'; -const RainbowGradientColorsFactory = darkMode => ({ +const RainbowGradientColorsFactory = (darkMode: boolean) => ({ inner: { - addCash: ['#FFB114', '#FF54BB', '#00F0FF'], default: darkModeThemeColors.gradients.rainbow, + backup: ['#14C7FF', '#7654FF', '#930AFF'], + disabledBackup: darkModeThemeColors.transparent, disabled: darkMode ? [darkModeThemeColors.blueGreyDark20, darkModeThemeColors.blueGreyDark20, darkModeThemeColors.blueGreyDark20] : ['#B0B3B9', '#B0B3B9', '#B0B3B9'], }, outer: { - addCash: ['#F5AA13', '#F551B4', '#00E6F5'], default: ['#F5AA13', '#F551B4', '#799DD5'], + backup: ['#14C7FF', '#7654FF', '#930AFF'], + disabledBackup: darkModeThemeColors.transparent, disabled: darkMode ? [darkModeThemeColors.blueGreyDark20, darkModeThemeColors.blueGreyDark20, darkModeThemeColors.blueGreyDark20] : ['#A5A8AE', '#A5A8AE', '#A5A8AE'], @@ -29,7 +32,7 @@ const RainbowGradientColorsFactory = darkMode => ({ const RainbowGradientColorsDark = RainbowGradientColorsFactory(true); const RainbowGradientColorsLight = RainbowGradientColorsFactory(false); -const RainbowButtonGradient = styled(RadialGradient).attrs(({ type, width }) => ({ +const RainbowButtonGradient = styled(RadialGradient).attrs(({ type, width }: any) => ({ radius: width, stops: type === RainbowButtonTypes.addCash ? [0, 0.544872, 1] : [0, 0.774321, 1], }))({ @@ -37,7 +40,7 @@ const RainbowButtonGradient = styled(RadialGradient).attrs(({ type, width }) => transform: [{ scaleY: 0.7884615385 }], }); -const InnerButton = styled(View)(({ strokeWidth, height, width, theme: { colors } }) => ({ +const InnerButton = styled(View)(({ strokeWidth, height, width, theme: { colors } }: any) => ({ ...margin.object(strokeWidth), backgroundColor: colors.dark, borderRadius: height / 2 - strokeWidth, @@ -45,25 +48,27 @@ const InnerButton = styled(View)(({ strokeWidth, height, width, theme: { colors width: width - strokeWidth * 2, })); -const InnerGradient = styled(RainbowButtonGradient).attrs(({ disabled, type, gradientColors }) => ({ +const InnerGradient = styled(RainbowButtonGradient).attrs(({ disabled, type, gradientColors }: any) => ({ colors: disabled - ? gradientColors.inner.disabled + ? type === RainbowButtonTypes.backup + ? gradientColors.inner.disabledBackup + : gradientColors.inner.disabled : type === RainbowButtonTypes.addCash ? gradientColors.inner.addCash : gradientColors.inner.default, -}))(({ width, height }) => ({ +}))(({ width, height }: any) => ({ height: width, top: -(width - height) / 2, width, })); -const OuterGradient = styled(RainbowButtonGradient).attrs(({ disabled, type, gradientColors }) => ({ +const OuterGradient = styled(RainbowButtonGradient).attrs(({ disabled, type, gradientColors }: any) => ({ colors: disabled ? gradientColors.outer.disabled : type === RainbowButtonTypes.addCash ? gradientColors.outer.addCash : gradientColors.outer.default, -}))(({ width, height }) => ({ +}))(({ width, height }: any) => ({ height: width * 2, left: -width / 2, top: -(width - height / 2), @@ -71,15 +76,23 @@ const OuterGradient = styled(RainbowButtonGradient).attrs(({ disabled, type, gra })); const WrapperView = android - ? styled.View({ - height: ({ height }) => height, + ? styled(View)({ + height: ({ height }: any) => height, overflow: 'hidden', position: 'absolute', - width: ({ width }) => width, + width: ({ width }: any) => width, }) - : ({ children }) => children; + : ({ children }: any) => children; -const RainbowButtonBackground = ({ disabled, height, strokeWidth, type, width }) => { +type RainbowButtonBackgroundProps = { + disabled: boolean; + height: number; + strokeWidth: number; + type: RainbowButtonTypes; + width: number; +}; + +const RainbowButtonBackground = ({ disabled, height, strokeWidth, type, width }: RainbowButtonBackgroundProps) => { const { isDarkMode } = useTheme(); const gradientColors = isDarkMode ? RainbowGradientColorsDark : RainbowGradientColorsLight; diff --git a/src/components/backup/BackupSheet.tsx b/src/components/backup/BackupSheet.tsx new file mode 100644 index 00000000000..be2bd57ebd8 --- /dev/null +++ b/src/components/backup/BackupSheet.tsx @@ -0,0 +1,58 @@ +import { RouteProp, useRoute } from '@react-navigation/native'; +import React, { useCallback } from 'react'; +import { BackupCloudStep, RestoreCloudStep } from '.'; +import WalletBackupStepTypes from '@/helpers/walletBackupStepTypes'; +import BackupChooseProviderStep from '@/components/backup/BackupChooseProviderStep'; +import { BackgroundProvider } from '@/design-system'; +import { SimpleSheet } from '@/components/sheet/SimpleSheet'; +import AddWalletToCloudBackupStep from '@/components/backup/AddWalletToCloudBackupStep'; +import BackupManuallyStep from './BackupManuallyStep'; +import { getHeightForStep } from '@/navigation/config'; +import { CloudBackupProvider } from './CloudBackupProvider'; + +type BackupSheetParams = { + BackupSheet: { + longFormHeight?: number; + missingPassword?: boolean; + step?: string; + walletId?: string; + nativeScreen?: boolean; + }; +}; + +export default function BackupSheet() { + const { params: { step = WalletBackupStepTypes.no_provider } = {} } = useRoute>(); + + const renderStep = useCallback(() => { + switch (step) { + case WalletBackupStepTypes.backup_now_to_cloud: + return ; + case WalletBackupStepTypes.backup_now_manually: + return ; + case WalletBackupStepTypes.backup_cloud: + return ; + case WalletBackupStepTypes.restore_from_backup: + return ; + case WalletBackupStepTypes.no_provider: + default: + return ; + } + }, [step]); + + return ( + + + {({ backgroundColor }) => ( + + {renderStep()} + + )} + + + ); +} diff --git a/src/components/backup/BackupSheetKeyboardLayout.js b/src/components/backup/BackupSheetKeyboardLayout.js deleted file mode 100644 index ef9386b8fe8..00000000000 --- a/src/components/backup/BackupSheetKeyboardLayout.js +++ /dev/null @@ -1,43 +0,0 @@ -import { useRoute } from '@react-navigation/native'; -import React from 'react'; -import { KeyboardArea } from 'react-native-keyboard-area'; -import { RainbowButton } from '../buttons'; -import { Column } from '../layout'; -import { SheetHandleFixedToTopHeight } from '../sheet'; -import KeyboardTypes from '@/helpers/keyboardTypes'; -import { useDimensions, useKeyboardHeight } from '@/hooks'; -import { sharedCoolModalTopOffset } from '@/navigation/config'; -import styled from '@/styled-thing'; -import { padding } from '@/styles'; - -const Footer = styled(Column)(({ isTallPhone }) => ({ - ...padding.object(20, 15, isTallPhone ? 65 : 50), - flexShrink: 0, - width: '100%', -})); - -const KeyboardSizeView = styled(KeyboardArea)({ - backgroundColor: ({ theme: { colors } }) => colors.transparent, -}); - -export default function BackupSheetKeyboardLayout({ children, footerButtonDisabled, footerButtonLabel, onSubmit, type }) { - const { params: { nativeScreen } = {} } = useRoute(); - const { height: deviceHeight, isTallPhone } = useDimensions(); - const keyboardHeight = useKeyboardHeight({ - keyboardType: KeyboardTypes.password, - }); - - const platformKeyboardHeight = android ? (type === 'restore' ? -10 : -30) : keyboardHeight; - - const sheetRegionAboveKeyboardHeight = deviceHeight - platformKeyboardHeight - sharedCoolModalTopOffset - SheetHandleFixedToTopHeight; - - return ( - - {children} -
- -
- {android ? : null} -
- ); -} diff --git a/src/components/backup/BackupSheetKeyboardLayout.tsx b/src/components/backup/BackupSheetKeyboardLayout.tsx new file mode 100644 index 00000000000..41ab5848657 --- /dev/null +++ b/src/components/backup/BackupSheetKeyboardLayout.tsx @@ -0,0 +1,62 @@ +import React, { PropsWithChildren } from 'react'; +import { KeyboardArea } from 'react-native-keyboard-area'; +import { RainbowButton } from '../buttons'; +import { Column } from '../layout'; +import styled from '@/styled-thing'; +import { padding } from '@/styles'; +import { Box } from '@/design-system'; +import { useDimensions } from '@/hooks'; +import { sharedCoolModalTopOffset } from '@/navigation/config'; +import RainbowButtonTypes from '../buttons/rainbow-button/RainbowButtonTypes'; + +const Footer = styled(Column)({ + ...padding.object(0, 24, 0), + flexShrink: 0, + justifyContent: 'flex-end', + width: '100%', + position: 'absolute', + bottom: 0, +}); + +const KeyboardSizeView = styled(KeyboardArea)({ + backgroundColor: ({ theme: { colors } }: any) => colors.transparent, +}); + +type BackupSheetKeyboardLayoutProps = PropsWithChildren<{ + footerButtonDisabled: boolean; + footerButtonLabel: string; + onSubmit: () => void; + type: 'backup' | 'restore'; +}>; + +type BackupSheetKeyboardLayout = { + BackupSheetKeyboardLayout: { + params: { + nativeButton?: boolean; + }; + }; +}; + +const MIN_HEIGHT = 740; + +export default function BackupSheetKeyboardLayout({ + children, + footerButtonDisabled, + footerButtonLabel, + onSubmit, +}: BackupSheetKeyboardLayoutProps) { + const { height: deviceHeight } = useDimensions(); + + const isSmallPhone = deviceHeight < MIN_HEIGHT; + const contentHeight = deviceHeight - (!isSmallPhone ? sharedCoolModalTopOffset : 0) - 100; + + return ( + + {children} +
+ +
+ {android ? : null} +
+ ); +} diff --git a/src/components/backup/BackupSheetSection.js b/src/components/backup/BackupSheetSection.tsx similarity index 59% rename from src/components/backup/BackupSheetSection.js rename to src/components/backup/BackupSheetSection.tsx index a2fa56d8412..cb658f224bd 100644 --- a/src/components/backup/BackupSheetSection.js +++ b/src/components/backup/BackupSheetSection.tsx @@ -1,21 +1,18 @@ import React, { Fragment, useEffect } from 'react'; import { useTheme } from '../../theme/ThemeContext'; -import Divider from '../Divider'; import { RainbowButton } from '../buttons'; import { Column, ColumnWithMargins } from '../layout'; import { SheetActionButton } from '../sheet'; import { Text } from '../text'; import { analytics } from '@/analytics'; -import BackupIcon from '@/assets/backupIcon.png'; -import BackupIconDark from '@/assets/backupIconDark.png'; -import { ImgixImage } from '@/components/images'; import styled from '@/styled-thing'; import { padding } from '@/styles'; +import { Bleed, Separator } from '@/design-system'; const Footer = styled(ColumnWithMargins).attrs({ margin: 19, })({ - ...padding.object(19, 15, 32), + ...padding.object(32, 15, 32), width: '100%', }); @@ -23,28 +20,26 @@ const Masthead = styled(Column).attrs({ align: 'center', justify: 'start', })({ + ...padding.object(32, 24, 40), flex: 1, - paddingTop: 8, }); -const MastheadDescription = styled(Text).attrs(({ theme: { colors } }) => ({ - align: 'center', - color: colors.alpha(colors.blueGreyDark, 0.5), - lineHeight: 'looser', - size: 'large', -}))({ ...padding.object(12, 42, 30) }); +type MaybePromise = T | Promise; -const MastheadIcon = styled(ImgixImage).attrs({ - resizeMode: ImgixImage.resizeMode.contain, -})({ - height: 74, - marginBottom: -1, - width: 75, - size: 75, -}); +type BackupSheetSectionProps = { + headerIcon?: React.ReactNode; + onPrimaryAction: () => MaybePromise; + onSecondaryAction: () => void; + primaryButtonTestId: string; + primaryLabel: string; + secondaryButtonTestId: string; + secondaryLabel: string; + titleText: string; + type: string; +}; export default function BackupSheetSection({ - descriptionText, + headerIcon, onPrimaryAction, onSecondaryAction, primaryButtonTestId, @@ -53,8 +48,8 @@ export default function BackupSheetSection({ secondaryLabel, titleText, type, -}) { - const { colors, isDarkMode } = useTheme(); +}: BackupSheetSectionProps) { + const { colors } = useTheme(); useEffect(() => { analytics.track('BackupSheet shown', { category: 'backup', @@ -65,13 +60,14 @@ export default function BackupSheetSection({ return ( - - + {headerIcon} + {titleText} - {descriptionText} - + + +