diff --git a/.env.mock b/.env.mock index dde3c50f28..af8e5eab8b 100644 --- a/.env.mock +++ b/.env.mock @@ -3,3 +3,4 @@ MOCK=1 DISABLE_YELLOW_BOX=1 MOCK_SCAN_RECIPIENT=bitcoin:3HX3Q4wgYi8nKakxv7kmdCgLWJFrFgcqEt?amount=0.001 FORCE_DEBUG_VISIBLE=1 +ADJUST_APP_TOKEN=cbxft2ch7wn4 \ No newline at end of file diff --git a/.env.production b/.env.production index d423a454ea..21f5a20ccf 100644 --- a/.env.production +++ b/.env.production @@ -1,4 +1,5 @@ APP_NAME="Ledger Live" SENTRY_DSN=https://beb25fd89630498990fd16bbc5b92fc1@sentry.io/273101 ANALYTICS_TOKEN=jfUZbw28ig8JpEi9DZpTUc21dKUKu1e3 -GOOGLE_SERVICE_INFO_NAME="GoogleService-Info-Production" \ No newline at end of file +GOOGLE_SERVICE_INFO_NAME="GoogleService-Info-Production" +ADJUST_APP_TOKEN=104p56owfekg \ No newline at end of file diff --git a/.env.staging b/.env.staging index f2c4a9f461..aebc23df71 100644 --- a/.env.staging +++ b/.env.staging @@ -1,4 +1,5 @@ APP_NAME="LL [STAGING]" SENTRY_DSN=https://beb25fd89630498990fd16bbc5b92fc1@sentry.io/273101 ANALYTICS_TOKEN=jfUZbw28ig8JpEi9DZpTUc21dKUKu1e3 -GOOGLE_SERVICE_INFO_NAME="GoogleService-Info-Staging" \ No newline at end of file +GOOGLE_SERVICE_INFO_NAME="GoogleService-Info-Staging" +ADJUST_APP_TOKEN=v88jjyrsto8w \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index d27f57e3a4..e038f95b26 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -88,7 +88,11 @@ import com.android.build.OutputFile */ project.ext.react = [ - enableHermes: false, // clean and rebuild if changing + /** + * Clean and rebuild if changing + * The following env var is not read from ../.env, you need to export this var like this: `export HERMES_ENABLED_ANDROID=true` + */ + enableHermes: true, // bundleInDebug: true, // Uncomment this to debug java without having to deal with JS dev server (metro) ] project.ext.sentryCli = [ @@ -123,9 +127,9 @@ def enableProguardInReleaseBuilds = false * give correct results when using with locales other than en-US. Note that * this variant is about 6MiB larger per architecture than default. */ -def jscFlavor = 'org.webkit:android-jsc-intl:+' +// def jscFlavor = 'org.webkit:android-jsc-intl:+' -def useIntlJsc = true +// def useIntlJsc = true /** * Whether to enable the Hermes VM. @@ -152,7 +156,7 @@ android { multiDexEnabled true minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 36176128 + versionCode 4195727 versionName "2.40.0" resValue "string", "build_config_package", "com.ledger.live" testBuildType System.getProperty('testBuildType', 'debug') @@ -204,12 +208,6 @@ android { matchingFallbacks = ['release'] } } - - // As required by https://github.com/react-native-community/jsc-android-buildscripts#for-react-native-version-059 - packagingOptions { - pickFirst '**/libjsc.so' - pickFirst '**/libc++_shared.so' - } } dependencies { @@ -234,16 +232,20 @@ dependencies { exclude group:'com.facebook.flipper' } - debugImplementation project(':flipper-plugin-rn-performance-android') - if (enableHermes) { - def hermesPath = "../../node_modules/hermes-engine/android/"; - debugImplementation files(hermesPath + "hermes-debug.aar") - releaseImplementation files(hermesPath + "hermes-release.aar") - } else { - implementation jscFlavor - } + def hermesPath = "../../node_modules/hermes-engine/android/"; + debugImplementation files(hermesPath + "hermes-debug.aar") + stagingReleaseImplementation files(hermesPath + "hermes-release.aar") + releaseImplementation files(hermesPath + "hermes-release.aar") + androidTestImplementation('com.wix:detox:+') + + compile project(':react-native-video') + implementation "androidx.appcompat:appcompat:1.0.0" + + // Adjust + compile 'com.google.android.gms:play-services-analytics:10.0.1' + compile 'com.android.installreferrer:installreferrer:1.0' } // Run this once to be able to run the application with BUCK diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 11b025724a..4c67e12770 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -8,3 +8,21 @@ # http://developer.android.com/guide/developing/tools/proguard.html # Add any project specific keep options here: + + +# Hermes config, cf. https://reactnative.dev/docs/hermes#android +-keep class com.facebook.hermes.unicode.** { *; } +-keep class com.facebook.jni.** { *; } + +-keep class com.adjust.sdk.** { *; } +-keep class com.google.android.gms.common.ConnectionResult { + int SUCCESS; +} +-keep class com.google.android.gms.ads.identifier.AdvertisingIdClient { + com.google.android.gms.ads.identifier.AdvertisingIdClient$Info getAdvertisingIdInfo(android.content.Context); +} +-keep class com.google.android.gms.ads.identifier.AdvertisingIdClient$Info { + java.lang.String getId(); + boolean isLimitAdTrackingEnabled(); +} +-keep public class com.android.installreferrer.** { *; } diff --git a/android/app/src/debug/java/com/ledger/live/ReactNativeFlipper.java b/android/app/src/debug/java/com/ledger/live/ReactNativeFlipper.java index 12f883515b..77024bc260 100644 --- a/android/app/src/debug/java/com/ledger/live/ReactNativeFlipper.java +++ b/android/app/src/debug/java/com/ledger/live/ReactNativeFlipper.java @@ -36,6 +36,7 @@ public static void initializeFlipper(Context context, ReactInstanceManager react client.addPlugin(new SharedPreferencesFlipperPlugin(context)); client.addPlugin(new RNPerfMonitorPlugin(reactInstanceManager)); client.addPlugin(CrashReporterPlugin.getInstance()); + client.addPlugin(new RNPerfMonitorPlugin(reactInstanceManager)); NetworkFlipperPlugin networkFlipperPlugin = new NetworkFlipperPlugin(); NetworkingModule.setCustomClientBuilder( @@ -71,4 +72,4 @@ public void run() { } } } -} \ No newline at end of file +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 704ffd196e..33c5fa42a4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -14,6 +14,8 @@ + + diff --git a/android/app/src/main/java/com/ledger/live/MainApplication.java b/android/app/src/main/java/com/ledger/live/MainApplication.java index 3dd89f6352..51a3445544 100644 --- a/android/app/src/main/java/com/ledger/live/MainApplication.java +++ b/android/app/src/main/java/com/ledger/live/MainApplication.java @@ -15,6 +15,7 @@ import com.facebook.react.bridge.JSIModulePackage; import com.swmansion.reanimated.ReanimatedJSIModulePackage; +import com.brentvatne.react.ReactVideoPackage; import java.lang.reflect.InvocationTargetException; import java.util.List; @@ -42,6 +43,7 @@ protected List getPackages() { @SuppressWarnings("UnnecessaryLocalVariable") List packages = new PackageList(this).getPackages(); packages.add(new BluetoothHelperPackage()); + packages.add(new ReactVideoPackage()); return packages; } diff --git a/android/build.gradle b/android/build.gradle index 68d9398261..d04621d0b7 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -35,11 +35,13 @@ allprojects { // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm url("$rootDir/../node_modules/react-native/android") } - maven { - // Android JSC is installed from npm - url("$rootDir/../node_modules/jsc-android/dist") + mavenCentral { + // We don't want to fetch react-native from Maven Central as there are + // older versions over there. + content { + excludeGroup "com.facebook.react" + } } - mavenCentral() google() maven { url 'https://jitpack.io' } maven { @@ -48,6 +50,7 @@ allprojects { maven { url "$rootDir/../node_modules/expo-camera/android/maven" } + jcenter() } configurations.all { resolutionStrategy { diff --git a/android/settings.gradle b/android/settings.gradle index bfbba79456..ed4212a03b 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -4,10 +4,10 @@ project(':react-native-webview').projectDir = new File(rootProject.projectDir, ' apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) -include ':flipper-plugin-rn-performance-android' -project(':flipper-plugin-rn-performance-android').projectDir = new File(rootProject.projectDir, '../node_modules/flipper-plugin-rn-performance-android') - include ':app' apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute().text.trim(), "../scripts/autolinking.gradle") -useExpoModules() \ No newline at end of file +useExpoModules() + +include ':react-native-video' +project(':react-native-video').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-video/android') \ No newline at end of file diff --git a/assets/videos/NanoX_LL0082.png b/assets/videos/NanoX_LL0082.png new file mode 100644 index 0000000000..a9838b36a7 Binary files /dev/null and b/assets/videos/NanoX_LL0082.png differ diff --git a/assets/videos/NanoX_LL0140.png b/assets/videos/NanoX_LL0140.png new file mode 100644 index 0000000000..39c69e0ce6 Binary files /dev/null and b/assets/videos/NanoX_LL0140.png differ diff --git a/assets/videos/NanoX_LL_White.mp4 b/assets/videos/NanoX_LL_White.mp4 new file mode 100644 index 0000000000..693525c538 Binary files /dev/null and b/assets/videos/NanoX_LL_White.mp4 differ diff --git a/assets/videos/NanoX_LL_White.webm b/assets/videos/NanoX_LL_White.webm new file mode 100644 index 0000000000..1f60d0259c Binary files /dev/null and b/assets/videos/NanoX_LL_White.webm differ diff --git a/assets/videos/NanoX_LL_black.mp4 b/assets/videos/NanoX_LL_black.mp4 new file mode 100644 index 0000000000..33a624407f Binary files /dev/null and b/assets/videos/NanoX_LL_black.mp4 differ diff --git a/assets/videos/NanoX_LL_black.webm b/assets/videos/NanoX_LL_black.webm new file mode 100644 index 0000000000..bc2a663fc1 Binary files /dev/null and b/assets/videos/NanoX_LL_black.webm differ diff --git a/assets/videos/ledger-card.webm b/assets/videos/ledger-card.webm new file mode 100644 index 0000000000..dc69abe527 Binary files /dev/null and b/assets/videos/ledger-card.webm differ diff --git a/assets/videos/nano-x.mp4 b/assets/videos/nano-x.mp4 new file mode 100644 index 0000000000..2b1b4edc35 Binary files /dev/null and b/assets/videos/nano-x.mp4 differ diff --git a/assets/videos/onboarding.mp4 b/assets/videos/onboarding.mp4 new file mode 100644 index 0000000000..67e31e065d Binary files /dev/null and b/assets/videos/onboarding.mp4 differ diff --git a/babel.config.js b/babel.config.js index 8a4543d7e8..0345aa9477 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,4 +1,7 @@ module.exports = { presets: ["module:metro-react-native-babel-preset"], - plugins: ["react-native-reanimated/plugin"], + plugins: [ + "react-native-reanimated/plugin", + "@babel/plugin-transform-named-capturing-groups-regex", + ], }; diff --git a/docs/analytics.md b/docs/analytics.md new file mode 100644 index 0000000000..7b044a0645 --- /dev/null +++ b/docs/analytics.md @@ -0,0 +1,44 @@ +### Analytics + +We use a lightweight opt-out analytics layer composed of different api and sdk. + +These tools are targetted towards internal contributors only or with + +- **_Adjust integration_** 🠒 Installs data analytics + + Several dev environments are available to track installs of apps + Debug, Staging and Prod + + In order to log events add this to your target build dot-env file + + ``` + DEBUG_ADJUST_LOGS=true + ``` + + For more details on how to work with the SDK check the adjust doc [here](https://github.com/adjust/react_native_sdk) + +* **_Segment integration_** 🠒 General use analytics + +in order to track events we use segment API with specific react API + +```js +import { Track, TrackScreen } from "../analytics"; +import Button from "./Button"; + +... + + + + ); +} + +export default memo(ButtonWrapped); diff --git a/src/components/CameraScreen/QRCodeBottomLayer.tsx b/src/components/CameraScreen/QRCodeBottomLayer.tsx new file mode 100644 index 0000000000..9c1c1374a9 --- /dev/null +++ b/src/components/CameraScreen/QRCodeBottomLayer.tsx @@ -0,0 +1,88 @@ +import React, { memo } from "react"; +import { StyleSheet } from "react-native"; +import { Trans } from "react-i18next"; + +import { Flex, Text, ProgressBar, Alert } from "@ledgerhq/native-ui"; +import { rgba } from "../../colors"; + +import { softMenuBarHeight } from "../../logic/getWindowDimensions"; + +type Props = { + progress?: number; + liveQrCode?: boolean; +}; + +function QrCodeBottomLayer({ progress, liveQrCode }: Props) { + return ( + + + + + + {progress !== undefined && progress > 0 && ( + + + + {Math.floor((progress || 0) * 100)}% + + + )} + + {liveQrCode ? ( + + + + + + + + + + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + darken: { + flexGrow: 1, + paddingBottom: softMenuBarHeight(), + }, + text: { + fontSize: 16, + lineHeight: 24, + textAlign: "center", + color: "#fff", + }, + centered: { + flex: 1, + alignItems: "center", + paddingTop: 8, + paddingHorizontal: 16, + }, +}); + +export default memo(QrCodeBottomLayer); diff --git a/src/components/CameraScreen/QRCodeTopLayer.tsx b/src/components/CameraScreen/QRCodeTopLayer.tsx new file mode 100644 index 0000000000..59d87a6c84 --- /dev/null +++ b/src/components/CameraScreen/QRCodeTopLayer.tsx @@ -0,0 +1,3 @@ +const QRCodeTopLayer = () => null; + +export default QRCodeTopLayer; diff --git a/src/components/Card.tsx b/src/components/Card.tsx new file mode 100644 index 0000000000..2968a506d0 --- /dev/null +++ b/src/components/Card.tsx @@ -0,0 +1,26 @@ +import React, { ReactNode } from "react"; +import { RectButton } from "react-native-gesture-handler"; +import { Flex } from "@ledgerhq/native-ui"; +import { FlexBoxProps } from "@ledgerhq/native-ui/components/Layout/Flex"; + +export type Props = FlexBoxProps & { + children?: ReactNode; + style?: any; + onPress?: () => void; +}; + +const Card = ({ children, onPress, ...props }: Props) => { + const content = () => ( + + {children} + + ); + + if (onPress) { + return {content()}; + } + + return content(); +}; + +export default Card; diff --git a/src/components/Carousel/Slide.tsx b/src/components/Carousel/Slide.tsx new file mode 100644 index 0000000000..956701fe16 --- /dev/null +++ b/src/components/Carousel/Slide.tsx @@ -0,0 +1,67 @@ +import React, { useCallback } from "react"; +import { Linking, Image } from "react-native"; +import { Flex, Text } from "@ledgerhq/native-ui"; +import Touchable from "../Touchable"; +import { track } from "../../analytics"; + +type SlideProps = { + url: string; + name: string; + title: string; + description: any; + image: any; + icon: any; + position: any; +}; + +const Slide = ({ + url, + name, + title, + description, + image, + icon, + position, +}: SlideProps) => { + const onClick = useCallback(() => { + track("Portfolio Recommended OpenUrl", { + url, + }); + Linking.openURL(url); + }, [url]); + return ( + + + + + {title} + + + {description} + + + + {image ? ( + + ) : icon ? ( + {icon} + ) : null} + + + + ); +}; + +export default Slide; diff --git a/src/components/Carousel/index.js b/src/components/Carousel/index.js index 532ffd66b1..1c42ee6e9b 100644 --- a/src/components/Carousel/index.js +++ b/src/components/Carousel/index.js @@ -16,7 +16,7 @@ import Button from "../Button"; import IconClose from "../../icons/Close"; import Slide from "./Slide"; -const SLIDES = [ +export const SLIDES = [ { url: urls.banners.ledgerAcademy, name: "LedgerAcademy", diff --git a/src/components/Carousel/index.tsx b/src/components/Carousel/index.tsx new file mode 100644 index 0000000000..4902600a6a --- /dev/null +++ b/src/components/Carousel/index.tsx @@ -0,0 +1,242 @@ +import React, { memo, useCallback, useMemo, useRef, useState } from "react"; +import { TouchableOpacity, ScrollView } from "react-native"; +import { useDispatch } from "react-redux"; +import map from "lodash/map"; +import { Trans } from "react-i18next"; +import { Box } from "@ledgerhq/native-ui"; +import { CloseMedium } from "@ledgerhq/native-ui/assets/icons"; +import styled from "styled-components/native"; +import Animated, { FadeOut, Layout } from "react-native-reanimated"; +import { urls } from "../../config/urls"; +import { setCarouselVisibility } from "../../actions/settings"; +import Slide from "./Slide"; +import Illustration from "../../images/illustration/Illustration"; +import AcademyLight from "../../images/illustration/Academy.light.png"; +import AcademyDark from "../../images/illustration/Academy.dark.png"; +import BuyCryptoLight from "../../images/illustration/BuyCrypto.light.png"; +import BuyCryptoDark from "../../images/illustration/BuyCrypto.dark.png"; +import SwapLight from "../../images/illustration/Swap.light.png"; +import SwapDark from "../../images/illustration/Swap.dark.png"; +import FamilyPackLight from "../../images/illustration/FamilyPack.light.png"; +import FamilyPackDark from "../../images/illustration/FamilyPack.dark.png"; +import { track } from "../../analytics"; + +const DismissCarousel = styled(TouchableOpacity)` + position: absolute; + top: 10px; + right: 10px; + width: 30px; + height: 30px; + align-items: center; + justify-content: center; +`; + +export const SLIDES = [ + { + url: urls.banners.ledgerAcademy, + name: "takeTour", + title: , + description: , + icon: ( + + ), + position: { + bottom: 70, + left: 15, + width: 146, + height: 93, + }, + }, + { + url: "ledgerlive://buy", + name: "buyCrypto", + title: , + description: , + icon: ( + + ), + position: { + bottom: 70, + left: 0, + width: 146, + height: 93, + }, + }, + { + url: "ledgerlive://swap", + name: "Swap", + title: , + description: , + icon: ( + + ), + position: { + bottom: 70, + left: 0, + width: 127, + height: 100, + }, + }, + { + url: urls.banners.familyPack, + name: "FamilyPack", + title: , + description: , + icon: ( + + ), + position: { + bottom: 70, + left: 0, + width: 180, + height: 80, + }, + }, +]; + +export const getDefaultSlides = () => + map(SLIDES, slide => ({ + id: slide.name, + Component: () => ( + + ), + })); + +const hitSlop = { + top: 16, + left: 16, + right: 16, + bottom: 16, +}; + +export const CAROUSEL_NONCE: number = 4; + +type CarouselCardProps = { + id: string; + children: React.ReactNode; + onHide: (cardId: string) => void; + index?: number; +}; + +const CarouselCard = ({ id, children, onHide, index }: CarouselCardProps) => ( + + {children} + onHide(id)}> + + + +); + +// TODO : make it generic in the ui +const CarouselCardContainer = ({ + id, + children, + onHide, + index, +}: CarouselCardProps) => ( + + {children} + +); + +type Props = { + cardsVisibility: boolean[]; +}; + +const Carousel = ({ cardsVisibility }: Props) => { + const dispatch = useDispatch(); + const scrollViewRef = useRef(null); + const [currentPositionX, setCurrentPositionX] = useState(0); + + const slides = useMemo( + () => + getDefaultSlides().filter(slide => { + if (!cardsVisibility[slide.id]) { + return false; + } + if (slide.start && slide.start > new Date()) { + return false; + } + if (slide.end && slide.end < new Date()) { + return false; + } + return true; + }), + [cardsVisibility], + ); + + const onHide = useCallback( + cardId => { + const slide = SLIDES.find(slide => slide.name === cardId); + if (slide) { + track("Portfolio Recommended CloseUrl", { + url: slide.url, + }); + } + dispatch(setCarouselVisibility({ ...cardsVisibility, [cardId]: false })); + }, + [dispatch, cardsVisibility], + ); + + const onScrollEnd = useCallback(event => { + setCurrentPositionX( + event.nativeEvent.contentOffset.x + + event.nativeEvent.layoutMeasurement.width, + ); + }, []); + + const onScrollViewContentChange = useCallback( + contentWidth => { + // 264px = CarouselCard width + padding + if (currentPositionX > contentWidth) { + scrollViewRef.current?.scrollToEnd({ animated: true }); + } + }, + [currentPositionX], + ); + + if (!slides.length) { + // No slides or dismissed, no problem + return null; + } + + return ( + + {slides.map(({ id, Component }, index) => ( + + + + + + ))} + + ); +}; + +export default memo(Carousel); diff --git a/src/components/CheckBox.tsx b/src/components/CheckBox.tsx new file mode 100644 index 0000000000..335bb9ce37 --- /dev/null +++ b/src/components/CheckBox.tsx @@ -0,0 +1,27 @@ +import React, { memo, useCallback } from "react"; + +import { Checkbox as RNCheckbox } from "@ledgerhq/native-ui"; + +type Props = { + isChecked: boolean; + onChange?: (value: boolean) => void; + disabled?: boolean; +}; + +function CheckBox({ isChecked, disabled, onChange, ...props }: Props) { + const onPress = useCallback(() => { + if (!onChange) return; + onChange(!isChecked); + }, [isChecked, onChange]); + + return ( + + ); +} + +export default memo(CheckBox); diff --git a/src/components/ChoiceButton.tsx b/src/components/ChoiceButton.tsx new file mode 100644 index 0000000000..00e1f97fb4 --- /dev/null +++ b/src/components/ChoiceButton.tsx @@ -0,0 +1,43 @@ +import React, { ReactNode } from "react"; +import Button from "./wrappedUi/Button"; + +type ChoiceButtonProps = { + disabled?: boolean; + onSelect: Function; + label: ReactNode; + description?: ReactNode; + Icon: any; + extra?: ReactNode; + event: string; + eventProperties: any; + navigationParams?: any[]; + enableActions?: string; +}; + +const ChoiceButton = ({ + event, + eventProperties, + disabled, + label, + Icon, + onSelect, + navigationParams, + enableActions, +}: ChoiceButtonProps) => ( + +); + +export default ChoiceButton; diff --git a/src/components/ChoiceCard.tsx b/src/components/ChoiceCard.tsx new file mode 100644 index 0000000000..da17ff3828 --- /dev/null +++ b/src/components/ChoiceCard.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { TouchableOpacityProps } from "react-native"; +import { Flex, Text } from "@ledgerhq/native-ui"; +import Touchable from "./Touchable"; + +const Card = ({ + title, + titleProps, + subTitle, + subTitleProps, + labelBadge, + onPress, + Image, + disabled, + ...props +}: { + title: string; + titleProps?: any; + subTitle?: string; + subTitleProps?: any; + labelBadge?: string; + Image: React.ReactNode; + onPress: TouchableOpacityProps["onPress"]; + disabled?: boolean; +}) => ( + + + + {labelBadge && ( + + {labelBadge} + + )} + + {title} + + {subTitle && ( + + {subTitle} + + )} + + + {Image} + + + +); + +export default Card; diff --git a/src/components/CounterValue.js b/src/components/CounterValue.js index c9930a1be5..5fe018c924 100644 --- a/src/components/CounterValue.js +++ b/src/components/CounterValue.js @@ -34,6 +34,7 @@ type Props = { // wrapper component from outside Wrapper?: React$ComponentType<*>, subMagnitude?: number, + joinFragmentsSeparator?: string, }; export const NoCountervaluePlaceholder = () => { diff --git a/src/components/CurrencyIcon.tsx b/src/components/CurrencyIcon.tsx new file mode 100644 index 0000000000..99e84c047d --- /dev/null +++ b/src/components/CurrencyIcon.tsx @@ -0,0 +1,96 @@ + +import React, { ComponentType, memo, useMemo } from "react"; +import { View } from "react-native"; +import { + getCryptoCurrencyIcon, + getTokenCurrencyIcon, +} from "@ledgerhq/live-common/lib/reactNative"; + +import { + CryptoCurrency, + Currency, + TokenCurrency, +} from "@ledgerhq/live-common/lib/types"; +import { Flex, Text } from "@ledgerhq/native-ui"; +import styled, { useTheme } from "styled-components/native"; + +import { useCurrencyColor } from "../helpers/getCurrencyColor"; + +const DefaultWrapper = styled(Flex)` + height: ${p => p.size}px; + width: ${p => p.size}px; + align-items: center; + justify-content: center; +`; + +const CircleWrapper = styled(Flex)` + border-radius: 9999px; + border: 1px solid transparent; + background: ${p => p.color}; + height: ${p => p.size}px; + width: ${p => p.size}px; + align-items: center; + justify-content: center; +`; + +type IconProps = { + size: number; + color: string; +}; + +type Icon = ComponentType; + +const getIconComponent = (currency: CryptoCurrency | TokenCurrency): Icon => { + const icon = + currency.type === "TokenCurrency" + ? getTokenCurrencyIcon(currency) + : getCryptoCurrencyIcon(currency); + + if (icon) { + return icon; + } + + return ({ size, ...props }: IconProps) => ( + + {currency.ticker[0]} + + ); +}; + +type Props = { + currency: Currency; + size: number; + color?: string; + radius?: number; + bg?: string; + circle?: boolean; +}; + +const CurrencyIcon = ({ size, currency, circle, color, radius, bg }: Props) => { + const { colors } = useTheme(); + const currencyColor = useCurrencyColor(currency, colors.background.main); + + const overrideColor = color || currencyColor; + + if (currency.type === "FiatCurrency") { + return null; + } + + const IconComponent = getIconComponent(currency); + + if (circle) { + return ( + + + + ); + } + + return ( + + + + ); +}; + +export default memo(CurrencyIcon); diff --git a/src/components/CurrencyRate.tsx b/src/components/CurrencyRate.tsx new file mode 100644 index 0000000000..5ba08c58e1 --- /dev/null +++ b/src/components/CurrencyRate.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { BigNumber } from "bignumber.js"; +import { + CryptoCurrency, + TokenCurrency, +} from "@ledgerhq/live-common/lib/types/currencies"; +import { Text } from "@ledgerhq/native-ui"; +import CounterValue from "./CounterValue"; +import CurrencyUnitValue from "./CurrencyUnitValue"; + +type Props = { + currency: CryptoCurrency | TokenCurrency; +}; + +export default function CurrencyRate({ currency }: Props) { + const one = new BigNumber(10 ** currency.units[0].magnitude); + + return ( + + + {" = "} + + + ); +} diff --git a/src/components/CurrencyUnitValue.tsx b/src/components/CurrencyUnitValue.tsx new file mode 100644 index 0000000000..8ef0792b32 --- /dev/null +++ b/src/components/CurrencyUnitValue.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { formatCurrencyUnit } from "@ledgerhq/live-common/lib/currencies"; +import { Unit } from "@ledgerhq/live-common/lib/types"; +import { useSelector } from "react-redux"; +import { BigNumber } from "bignumber.js"; + +import { discreetModeSelector, localeSelector } from "../reducers/settings"; + +type Props = { + unit: Unit; + value: BigNumber | number; + showCode?: boolean; + alwaysShowSign?: boolean; + before?: string; + after?: string; + disableRounding?: boolean; + joinFragmentsSeparator?: string; +}; + +const CurrencyUnitValue = ({ + unit, + value: valueProp, + showCode = true, + alwaysShowSign, + before = "", + after = "", + disableRounding = false, + joinFragmentsSeparator = "", +}: Props): JSX.Element => { + const locale = useSelector(localeSelector); + const discreet = useSelector(discreetModeSelector); + const value = + valueProp instanceof BigNumber ? valueProp : new BigNumber(valueProp); + + return ( + <> + {before + + (value + ? formatCurrencyUnit(unit, value, { + showCode, + alwaysShowSign, + locale, + disableRounding, + discreet, + joinFragmentsSeparator, + }) + : "") + + after} + + ); +}; + +export default CurrencyUnitValue; diff --git a/src/components/CustomTabBar.tsx b/src/components/CustomTabBar.tsx new file mode 100644 index 0000000000..9e89fdcc49 --- /dev/null +++ b/src/components/CustomTabBar.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { useTheme } from "styled-components/native"; +import { Flex } from "@ledgerhq/native-ui"; +import { TouchableOpacity } from "react-native"; +import Svg, { Path } from "react-native-svg"; + +type SvgProps = { + color: string; +}; + +function TabBarShape({ color }: SvgProps) { + return ( + + + + + + ); +} + +export default function CustomTabBar({ + state, + descriptors, + navigation, + colors, +}: any) { + return ( + + + + + + + {state.routes.map((route, index) => { + const { options } = descriptors[route.key]; + const label = + options.tabBarLabel !== undefined + ? options.tabBarLabel + : options.title !== undefined + ? options.title + : route.name; + const Icon = options.tabBarIcon; + + const isFocused = state.index === index; + + const onPress = () => { + const event = navigation.emit({ + type: "tabPress", + target: route.key, + canPreventDefault: true, + }); + + if (!isFocused && !event.defaultPrevented) { + // The `merge: true` option makes sure that the params inside the tab screen are preserved + navigation.navigate({ name: route.name, merge: true }); + } + }; + + const onLongPress = () => { + navigation.emit({ + type: "tabLongPress", + target: route.key, + }); + }; + + return ( + + + + ); + })} + + ); +} diff --git a/src/components/DelegationDrawer.js b/src/components/DelegationDrawer.js index 78c0ae96e7..55125ad692 100644 --- a/src/components/DelegationDrawer.js +++ b/src/components/DelegationDrawer.js @@ -13,7 +13,6 @@ import type { AccountLike } from "@ledgerhq/live-common/lib/types"; // TODO move to component import { useTheme } from "@react-navigation/native"; import DelegatingContainer from "../families/tezos/DelegatingContainer"; -import Close from "../icons/Close"; import { rgba } from "../colors"; import getWindowDimensions from "../logic/getWindowDimensions"; import BottomModal from "./BottomModal"; @@ -53,7 +52,6 @@ export default function DelegationDrawer({ undelegation, icon, }: Props) { - const { colors } = useTheme(); const currency = getAccountCurrency(account); const color = getCurrencyColor(currency); const unit = getAccountUnit(account); @@ -68,16 +66,6 @@ export default function DelegationDrawer({ onClose={onClose} > - - - - - - 0 + ? ["success.c100", ArrowUpMedium, "+"] + : ["error.c100", ArrowDownMedium, "-"] + : ["neutral.c100", () => null, ""]; + + return ( + + {percent && ArrowIcon ? : null} + + + {unit && absDelta !== 0 ? ( + + ) : percent ? ( + `${absDelta.toFixed(0)}%` + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flexDirection: "row", + alignItems: "center", + }, + content: { + marginLeft: 5, + }, +}); + +export default memo(Delta); diff --git a/src/components/DeviceAction/getDeviceAnimation.js b/src/components/DeviceAction/getDeviceAnimation.js index 5fbc58ac0c..ca066236eb 100644 --- a/src/components/DeviceAction/getDeviceAnimation.js +++ b/src/components/DeviceAction/getDeviceAnimation.js @@ -84,8 +84,8 @@ const animations = { }, bluetooth: { plugAndPinCode: { - light: null, - dark: null, + light: require("../../animations/nanoX/bluetooth/3EnterPinCode/light.json"), + dark: require("../../animations/nanoX/bluetooth/3EnterPinCode/dark.json"), }, enterPinCode: { light: require("../../animations/nanoX/bluetooth/3EnterPinCode/light.json"), diff --git a/src/components/DeviceAction/rendering.tsx b/src/components/DeviceAction/rendering.tsx new file mode 100644 index 0000000000..c8da364cd9 --- /dev/null +++ b/src/components/DeviceAction/rendering.tsx @@ -0,0 +1,549 @@ +import React, { useEffect } from "react"; +import { useDispatch } from "react-redux"; +import styled from "styled-components/native"; +import { WrongDeviceForAccount, UnexpectedBootloader } from "@ledgerhq/errors"; +import { TokenCurrency } from "@ledgerhq/live-common/lib/types"; +import { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import { AppRequest } from "@ledgerhq/live-common/lib/hw/actions/app"; +import { + InfiniteLoader, + Text, + Flex, + Tag, + Icons, + Log, +} from "@ledgerhq/native-ui"; +import { setModalLock } from "../../actions/appstate"; +import { urls } from "../../config/urls"; +import Alert from "../Alert"; +import { lighten } from "../../colors"; +import Button from "../Button"; +import { NavigatorName, ScreenName } from "../../const"; +import Animation from "../Animation"; +import getDeviceAnimation from "./getDeviceAnimation"; +import GenericErrorView from "../GenericErrorView"; +import Circle from "../Circle"; +import { MANAGER_TABS } from "../../screens/Manager/Manager"; +import ExternalLink from "../ExternalLink"; +import { track } from "../../analytics"; + +const Wrapper = styled(Flex).attrs({ + flex: 1, + alignItems: "center", + justifyContent: "center", + minHeight: "160px", +})``; + +const AnimationContainer = styled(Flex).attrs(p => ({ + alignSelf: "stretch", + alignItems: "center", + justifyContent: "center", + height: p.withConnectDeviceHeight + ? "100px" + : p.withVerifyAddressHeight + ? "72px" + : undefined, +}))``; + +const ActionContainer = styled(Flex).attrs({ + alignSelf: "stretch", +})``; + +const SpinnerContainer = styled(Flex).attrs({ + padding: 24, +})``; + +const IconContainer = styled(Flex).attrs({ + margin: 6, +})``; + +const CenteredText = styled(Text).attrs({ + fontWeight: "medium", + textAlign: "center", +})``; + +const TitleContainer = styled(Flex).attrs({ + py: 8, +})``; + +const TitleText = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +const DescriptionText = styled(CenteredText).attrs({ + variant: "bodyLineHeight", + py: "8px", + fontWeight: "medium", + color: "neutral.c70", +})``; + +const ConnectDeviceNameText = styled(Tag).attrs({ + my: "8", +})``; + +const StyledButton = styled(Button).attrs({ + mt: 6, + alignSelf: "stretch", +})``; + +const ConnectDeviceExtraContentWrapper = styled(Flex).attrs({ + mb: 8, +})``; + +type RawProps = { + t: (key: string, options?: { [key: string]: string | number }) => string; + colors?: any; + theme?: "light" | "dark"; +}; + +export function renderRequestQuitApp({ + t, + device, + theme, +}: RawProps & { + device: Device; +}) { + return ( + + + + + {t("DeviceAction.quitApp")} + + ); +} + +export function renderRequiresAppInstallation({ + t, + navigation, + appNames, +}: RawProps & { + navigation: any; + appNames: string[]; +}) { + const appNamesCSV = appNames.join(", "); + + return ( + + + {t("DeviceAction.appNotInstalled", { + appName: appNamesCSV, + count: appNames.length, + })} + + + + navigation.navigate(NavigatorName.Manager, { + screen: ScreenName.Manager, + params: { searchQuery: appNamesCSV }, + }) + } + /> + + + ); +} + +export function renderVerifyAddress({ + t, + device, + currencyName, + onPress, + address, + theme, +}: RawProps & { + device: Device; + currencyName: string; + onPress?: () => void; + address?: string; +}) { + return ( + + + + + {t("DeviceAction.verifyAddress.title")} + + {t("DeviceAction.verifyAddress.description", { currencyName })} + + + {onPress && ( + + )} + {address && {address}} + + + ); +} + +export function renderConfirmSwap({ + t, + device, + theme, +}: RawProps & { + device: Device; +}) { + return ( + + + {t("DeviceAction.confirmSwap.alert")} + + + + + {t("DeviceAction.confirmSwap.title")} + + ); +} + +export function renderConfirmSell({ + t, + device, +}: RawProps & { + device: Device; +}) { + return ( + + + {t("DeviceAction.confirmSell.alert")} + + + + + {t("DeviceAction.confirmSell.title")} + + ); +} + +export function renderAllowManager({ + t, + wording, + device, + theme, +}: RawProps & { + wording: string; + device: Device; +}) { + // TODO: disable gesture, modal close, hide header buttons + return ( + + + + + + {t("DeviceAction.allowManagerPermission", { wording })} + + + ); +} + +const AllowOpeningApp = ({ + t, + navigation, + wording, + tokenContext, + isDeviceBlocker, + device, + theme, +}: RawProps & { + navigation: any; + wording: string; + tokenContext?: TokenCurrency | null | undefined; + isDeviceBlocker?: boolean; + device: Device; +}) => { + useEffect(() => { + if (isDeviceBlocker) { + // TODO: disable gesture, modal close, hide header buttons + navigation.setOptions({ + gestureEnabled: false, + }); + } + }, [isDeviceBlocker, navigation]); + + return ( + + + + + {t("DeviceAction.allowAppPermission", { wording })} + {tokenContext ? ( + + {t("DeviceAction.allowAppPermissionSubtitleToken", { + token: tokenContext.name, + })} + + ) : null} + + ); +}; + +export function renderAllowOpeningApp({ + t, + navigation, + wording, + tokenContext, + isDeviceBlocker, + device, + theme, +}: RawProps & { + navigation: any; + wording: string; + tokenContext?: TokenCurrency | undefined | null; + isDeviceBlocker?: boolean; + device: Device; +}) { + return ( + + ); +} + +export function renderInWrongAppForAccount({ + t, + onRetry, + colors, + theme, +}: RawProps & { + onRetry?: () => void; +}) { + return renderError({ + t, + error: new WrongDeviceForAccount(), + onRetry, + colors, + theme, + }); +} + +export function renderError({ + t, + error, + onRetry, + managerAppName, + navigation, +}: RawProps & { + navigation?: any; + error: Error; + onRetry?: () => void; + managerAppName?: string; +}) { + const onPress = () => { + if (managerAppName && navigation) { + navigation.navigate(NavigatorName.Manager, { + screen: ScreenName.Manager, + params: { + tab: MANAGER_TABS.INSTALLED_APPS, + updateModalOpened: true, + }, + }); + } else if (onRetry) { + onRetry(); + } + }; + return ( + + + {onRetry || managerAppName ? ( + + + + ) : null} + + ); +} + +export function renderConnectYourDevice({ + t, + unresponsive, + device, + theme, + onSelectDeviceLink, +}: RawProps & { + unresponsive: boolean; + device: Device; + onSelectDeviceLink?: () => void; +}) { + return ( + + + + + {device.deviceName && ( + {device.deviceName} + )} + + {t( + unresponsive + ? "DeviceAction.unlockDevice" + : device.wired + ? "DeviceAction.connectAndUnlockDevice" + : "DeviceAction.turnOnAndUnlockDevice", + )} + + {onSelectDeviceLink ? ( + + + + ) : null} + + ); +} + +export function renderLoading({ + t, + description, +}: RawProps & { + description?: string; +}) { + return ( + + + + + {description ?? t("DeviceAction.loading")} + + ); +} + +export function LoadingAppInstall({ + analyticsPropertyFlow = "unknown", + request, + ...props +}: RawProps & { + analyticsPropertyFlow: string; + description?: string; + request?: AppRequest; +}) { + const dispatch = useDispatch(); + + useEffect(() => { + // Nb Blocks closing the modal while the install is happening. + // releases the block on onmount. + dispatch(setModalLock(true)); + return () => { + dispatch(setModalLock(false)); + }; + }, [dispatch]); + + const currency = request?.currency || request?.account?.currency; + const appName = request?.appName || currency?.managerAppName; + useEffect(() => { + const trackingArgs = [ + "In-line app install", + { appName, flow: analyticsPropertyFlow }, + ]; + track(...trackingArgs); + }, [appName, analyticsPropertyFlow]); + return renderLoading(props); +} + +type WarningOutdatedProps = RawProps & { + colors: any; + navigation: any; + appName: string; + passWarning: () => void; +}; + +export function renderWarningOutdated({ + t, + navigation, + appName, + passWarning, + colors, +}: WarningOutdatedProps) { + function onOpenManager() { + navigation.navigate(NavigatorName.Manager); + } + + return ( + + + + + + + {t("DeviceAction.outdated")} + + {t("DeviceAction.outdatedDesc", { appName })} + + + + + + + ); +} + +export function renderBootloaderStep({ t, colors, theme }: RawProps) { + return renderError({ + t, + error: new UnexpectedBootloader(), + colors, + theme, + }); +} diff --git a/src/components/DeviceActionModal.js b/src/components/DeviceActionModal.js index e9522e404e..c0a4b68b8e 100644 --- a/src/components/DeviceActionModal.js +++ b/src/components/DeviceActionModal.js @@ -1,17 +1,14 @@ // @flow import React from "react"; import { View, StyleSheet } from "react-native"; -import { useSelector } from "react-redux"; import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; import { SyncSkipUnderPriority } from "@ledgerhq/live-common/lib/bridge/react"; import { useTheme } from "@react-navigation/native"; import { useTranslation } from "react-i18next"; -import { isModalLockedSelector } from "../reducers/appstate"; import DeviceAction from "./DeviceAction"; import BottomModal from "./BottomModal"; import ModalBottomAction from "./ModalBottomAction"; -import Close from "../icons/Close"; -import Touchable from "./Touchable"; + import InfoBox from "./InfoBox"; type Props = { @@ -74,25 +71,10 @@ export default function DeviceActionModal({ /> )} {device && } - - - - - ); } -const ModalLockAwareClose = ({ children }) => { - const modalLock = useSelector(isModalLockedSelector); - if (modalLock) return null; - return children; -}; - const styles = StyleSheet.create({ footerContainer: { flexDirection: "row", diff --git a/src/components/DeviceActionModal.tsx b/src/components/DeviceActionModal.tsx new file mode 100644 index 0000000000..89adc5cee5 --- /dev/null +++ b/src/components/DeviceActionModal.tsx @@ -0,0 +1,78 @@ +import React, { useState } from "react"; +import { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import { SyncSkipUnderPriority } from "@ledgerhq/live-common/lib/bridge/react"; +import styled from "styled-components/native"; +import { Alert, Flex } from "@ledgerhq/native-ui"; +import { useTranslation } from "react-i18next"; +import DeviceAction from "./DeviceAction"; +import BottomModal from "./BottomModal"; + +const DeviceActionContainer = styled(Flex).attrs({ + flexDirection: "row", +})``; + +type Props = { + // TODO: fix action type + action: any; + device: Device | null | undefined; + // TODO: fix request type + request?: any; + onClose?: () => void; + onModalHide?: () => void; + onResult?: (payload: any) => Promise | void; + renderOnResult?: (p: any) => React.ReactNode; + onSelectDeviceLink?: () => void; + analyticsPropertyFlow?: string; +}; + +export default function DeviceActionModal({ + action, + device, + request, + onClose, + onResult, + renderOnResult, + onModalHide, + onSelectDeviceLink, + analyticsPropertyFlow, +}: Props) { + const { t } = useTranslation(); + const showAlert = !device?.wired; + const [result, setResult] = useState(null); + return ( + { + if (onModalHide) onModalHide(); + if (result) onResult(...result); + setResult(null); + }} + > + {result + ? null + : device && ( + + + { + setResult([...props]); + }} + renderOnResult={renderOnResult} + onSelectDeviceLink={onSelectDeviceLink} + analyticsPropertyFlow={analyticsPropertyFlow} + /> + + {showAlert && ( + + )} + + )} + {device && } + + ); +} diff --git a/src/components/DeviceJob/StepRunnerModal.js b/src/components/DeviceJob/StepRunnerModal.js index 3869647a74..e05dfd73c5 100644 --- a/src/components/DeviceJob/StepRunnerModal.js +++ b/src/components/DeviceJob/StepRunnerModal.js @@ -1,12 +1,9 @@ // @flow import React from "react"; -import { StyleSheet } from "react-native"; import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; import { useTheme } from "@react-navigation/native"; import BottomModal from "../BottomModal"; -import Close from "../../icons/Close"; -import Touchable from "../Touchable"; import type { Step } from "./types"; import type { DeviceNames } from "../../screens/Onboarding/types"; import { ErrorFooterGeneric, RenderError } from "./StepRenders"; @@ -52,17 +49,6 @@ export default function SelectDeviceConnectModal({ colors={colors} /> ) : null} - - - ); } - -const styles = StyleSheet.create({ - close: { - position: "absolute", - right: 16, - top: 16, - }, -}); diff --git a/src/components/DiscreetModeButton.tsx b/src/components/DiscreetModeButton.tsx new file mode 100644 index 0000000000..38f453cf91 --- /dev/null +++ b/src/components/DiscreetModeButton.tsx @@ -0,0 +1,31 @@ +import React, { useCallback } from "react"; +import { TouchableOpacity, StyleSheet } from "react-native"; +import { useDispatch, useSelector } from "react-redux"; +import { EyeMedium, EyeNoneMedium } from "@ledgerhq/native-ui/assets/icons"; +import { discreetModeSelector } from "../reducers/settings"; +import { setDiscreetMode } from "../actions/settings"; + +export default function DiscreetModeButton({size = 24} : {size?: number}) { + const discreetMode = useSelector(discreetModeSelector); + const dispatch = useDispatch(); + const onPress = useCallback(() => { + dispatch(setDiscreetMode(!discreetMode)); + }, [discreetMode, dispatch]); + + return ( + + {discreetMode ? ( + + ) : ( + + )} + + ); +} + +const styles = StyleSheet.create({ + root: { + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/src/components/EditFeeUnit.js b/src/components/EditFeeUnit.js index 1b8c162afd..634e043501 100644 --- a/src/components/EditFeeUnit.js +++ b/src/components/EditFeeUnit.js @@ -1,7 +1,7 @@ /* @flow */ import React, { useState } from "react"; import { FlatList, View, StyleSheet, Keyboard } from "react-native"; -import { useNavigation, useRoute, useTheme } from "@react-navigation/native"; +import { useNavigation, useRoute } from "@react-navigation/native"; import Icon from "react-native-vector-icons/dist/FontAwesome"; import type { Account } from "@ledgerhq/live-common/lib/types"; import { getAccountBridge } from "@ledgerhq/live-common/lib/bridge"; @@ -15,7 +15,6 @@ import CurrencyInput from "./CurrencyInput"; import Touchable from "./Touchable"; import BottomModal from "./BottomModal"; import Button from "./Button"; -import CloseIcon from "../icons/Close"; type Props = { account: Account, @@ -23,7 +22,6 @@ type Props = { }; export default function EditFreeUnit({ account, field }: Props) { - const { colors } = useTheme(); const { navigate } = useNavigation(); const route = useRoute(); const { t } = useTranslation(); @@ -117,13 +115,6 @@ export default function EditFreeUnit({ account, field }: Props) { {t("send.fees.edit.title")} - - - { const { colors } = useTheme(); - const c = color || colors.live; + const c = color || colors.primary.c80; return ( ; + /** deprecated, use `iconPosition` instead */ + iconFirst?: boolean; + iconPosition?: LinkProps["iconPosition"]; + onPress?: LinkProps["onPress"]; + text: LinkProps["children"]; + type?: LinkProps["type"]; +}; + +export default function ExternalLink({ + disabled, + event, + eventProperties, + Icon, + iconFirst, + iconPosition, + onPress, + text, + type, +}: Props) { + const handlePress = useCallback( + nativeEvent => { + if (event) { + track(event, ...(eventProperties ? [eventProperties] : [])); + } + onPress && onPress(nativeEvent); + }, + [event, eventProperties, onPress], + ); + + return ( + + {text} + + ); +} diff --git a/src/components/FabAccountButtonBar.tsx b/src/components/FabAccountButtonBar.tsx new file mode 100644 index 0000000000..45e02823cf --- /dev/null +++ b/src/components/FabAccountButtonBar.tsx @@ -0,0 +1,150 @@ +import React, { + useCallback, + memo, + useState, + ComponentType, + ReactElement, + ReactNode, +} from "react"; +import { useNavigation } from "@react-navigation/native"; + +import { AccountLike, Account } from "@ledgerhq/live-common/lib/types"; + +import { Flex } from "@ledgerhq/native-ui"; +import ChoiceButton from "./ChoiceButton"; +import InfoModal from "./InfoModal"; +import Button from "./wrappedUi/Button"; + +type ActionButtonEventProps = { + navigationParams?: any[]; + confirmModalProps?: { + withCancel?: boolean; + id?: string; + title?: string | ReactElement; + desc?: string | ReactElement; + Icon?: ComponentType; + children?: ReactNode; + confirmLabel?: string | ReactElement; + confirmProps?: any; + }; + Component?: ComponentType; + enableActions?: string; +}; + +type ActionButton = ActionButtonEventProps & { + label: ReactNode; + Icon?: ComponentType<{ size: number; color: string }>; + event: string; + eventProperties?: { [key: string]: any }; + Component?: ComponentType; +}; + +type Props = { + buttons: ActionButton[]; + actions?: { default: ActionButton[]; lending?: ActionButton[] }; + account?: AccountLike; + parentAccount?: Account; +}; + +function FabAccountButtonBar({ + buttons, + actions, + account, + parentAccount, +}: Props) { + const navigation = useNavigation(); + + const [infoModalProps, setInfoModalProps] = useState< + ActionButtonEventProps | undefined + >(); + const [isModalInfoOpened, setIsModalInfoOpened] = useState(); + + const onNavigate = useCallback( + (name: string, options?: any) => { + const accountId = account ? account.id : undefined; + const parentId = parentAccount ? parentAccount.id : undefined; + navigation.navigate(name, { + ...options, + params: { + ...(options ? options.params : {}), + accountId, + parentId, + }, + }); + }, + [account, parentAccount, navigation], + ); + + const onPress = useCallback( + (data: ActionButtonEventProps) => { + const { navigationParams, confirmModalProps } = data; + if (!confirmModalProps) { + setInfoModalProps(); + if (navigationParams) onNavigate(...navigationParams); + } else { + setInfoModalProps(data); + setIsModalInfoOpened(true); + } + }, + [onNavigate, setIsModalInfoOpened], + ); + + const onContinue = useCallback(() => { + setIsModalInfoOpened(false); + onPress({ ...infoModalProps, confirmModalProps: undefined }); + }, [infoModalProps, onPress]); + + const onClose = useCallback(() => { + setIsModalInfoOpened(); + }, []); + + const onChoiceSelect = useCallback(({ navigationParams }) => { + if (navigationParams) { + onNavigate(...navigationParams); + } + }, []); + + return ( + + {buttons.map( + ( + { label, Icon, event, eventProperties, Component, ...rest }, + index, + ) => ( + + ), + )} + {actions?.default?.map((a, i) => + a.Component ? ( + + ) : ( + + ), + )} + {isModalInfoOpened && infoModalProps && ( + + )} + + ); +} + +export default memo(FabAccountButtonBar); diff --git a/src/components/FabActions.tsx b/src/components/FabActions.tsx new file mode 100644 index 0000000000..c7d50da423 --- /dev/null +++ b/src/components/FabActions.tsx @@ -0,0 +1,173 @@ +import React from "react"; + +import { useTheme } from "@react-navigation/native"; +import { Trans } from "react-i18next"; +import { useSelector } from "react-redux"; +import { PlusMedium } from "@ledgerhq/native-ui/assets/icons"; + +import { getAccountCurrency } from "@ledgerhq/live-common/lib/account"; + +import { AccountLike, Account } from "@ledgerhq/live-common/lib/types"; + +import { isCurrencySupported } from "../screens/Exchange/coinifyConfig"; + +import { + readOnlyModeEnabledSelector, + swapSelectableCurrenciesSelector, +} from "../reducers/settings"; +import { accountsCountSelector } from "../reducers/accounts"; +import { NavigatorName, ScreenName } from "../const"; +import FabAccountButtonBar from "./FabAccountButtonBar"; +import Exchange from "../icons/Exchange"; +import Swap from "../icons/Swap"; +import useActions from "../screens/Account/hooks/useActions"; +import useLendingActions from "../screens/Account/hooks/useLendingActions"; + +type Props = { + account?: AccountLike; + parentAccount?: Account; +}; + +type FabAccountActionsProps = { + account: AccountLike; + parentAccount?: Account; +}; + +function FabAccountActions({ account, parentAccount }: FabAccountActionsProps) { + const { colors } = useTheme(); + + const currency = getAccountCurrency(account); + const swapSelectableCurrencies = useSelector( + swapSelectableCurrenciesSelector, + ); + const availableOnSwap = + swapSelectableCurrencies.includes(currency.id) && account.balance.gt(0); + const readOnlyModeEnabled = useSelector(readOnlyModeEnabledSelector); + + const canBeBought = isCurrencySupported(currency, "buy"); + + const allActions = [ + ...(!readOnlyModeEnabled && canBeBought + ? [ + { + navigationParams: [ + NavigatorName.ExchangeBuyFlow, + { + screen: ScreenName.ExchangeConnectDevice, + params: { + account, + mode: "buy", + parentId: + account.type !== "Account" ? account.parentId : undefined, + }, + }, + ], + label: , + Icon: PlusMedium, + event: "Buy Crypto Account Button", + eventProperties: { + currencyName: currency.name, + }, + }, + ] + : []), + ...(availableOnSwap + ? [ + { + navigationParams: [ + NavigatorName.Swap, + { + screen: ScreenName.Swap, + params: { + defaultAccount: account, + defaultParentAccount: parentAccount, + }, + }, + ], + label: ( + + ), + Icon: Swap, + event: "Swap Crypto Account Button", + eventProperties: { currencyName: currency.name }, + }, + ] + : []), + ...useActions({ account, parentAccount, colors }), + ]; + + // Do not display separators as buttons. (they do not have a label) + // + // First, count the index at which there are 2 valid buttons. + let counter = 0; + const sliceIndex = + allActions.findIndex(action => { + if (action.label) counter++; + return counter === 2; + }) + 1; + + // Then slice from 0 to the index and ignore invalid button elements. + // Chaining methods should not be a big deal given the size of the actions array. + const buttons = allActions + .slice(0, sliceIndex || undefined) + .filter(action => !!action.label) + .slice(0, 2); + + const actions = { + default: sliceIndex ? allActions.slice(sliceIndex) : [], + lending: useLendingActions({ account }), + }; + + return ( + + ); +} + +function FabActions({ account, parentAccount }: Props) { + const readOnlyModeEnabled = useSelector(readOnlyModeEnabledSelector); + const accountsCount = useSelector(accountsCountSelector); + + if (account) + return ( + + ); + + const actions = [ + { + event: "TransferExchange", + label: , + Icon: Exchange, + navigationParams: [ + NavigatorName.Exchange, + { screen: ScreenName.ExchangeBuy }, + ], + }, + ...(accountsCount > 0 && !readOnlyModeEnabled + ? [ + { + event: "TransferSwap", + label: , + Icon: Swap, + navigationParams: [ + NavigatorName.Swap, + { + screen: ScreenName.Swap, + }, + ], + }, + ] + : []), + ]; + + return ; +} + +export default FabActions; diff --git a/src/components/FilteredSearchBar.tsx b/src/components/FilteredSearchBar.tsx new file mode 100644 index 0000000000..0d2ab43bee --- /dev/null +++ b/src/components/FilteredSearchBar.tsx @@ -0,0 +1,52 @@ +import React, { ReactNode, useState } from "react"; +import { SearchInput } from "@ledgerhq/native-ui"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "styled-components/native"; + +import Search from "./Search"; + +type Props = { + initialQuery?: string; + renderList: (list: any[]) => ReactNode; + renderEmptySearch: () => ReactNode; + keys?: string[]; + list: any[]; + inputWrapperStyle?: any; +}; + +const FilteredSearchBar = ({ + keys = ["name"], + initialQuery, + renderList, + list, + renderEmptySearch, +}: Props) => { + const { t } = useTranslation(); + const { colors } = useTheme(); + const [query, setQuery] = useState(initialQuery || ""); + + return ( + <> + + + + ); +}; + +export default FilteredSearchBar; diff --git a/src/components/FirmwareUpdateBanner.js b/src/components/FirmwareUpdateBanner.js index 5786a1821f..60b3f56c9e 100644 --- a/src/components/FirmwareUpdateBanner.js +++ b/src/components/FirmwareUpdateBanner.js @@ -4,10 +4,10 @@ import React, { useState, useEffect, useContext } from "react"; import { View, - TouchableOpacity, TouchableHighlight, StyleSheet, Platform, + TouchableOpacity, } from "react-native"; import manager from "@ledgerhq/live-common/lib/manager"; import * as Animatable from "react-native-animatable"; @@ -27,8 +27,8 @@ import { hasConnectedDeviceSelector } from "../reducers/appstate"; import IconExclamation from "../icons/ExclamationCircleFull"; import { BaseButton as Button } from "./Button"; import IconDownload from "../icons/Download"; -import BottomModal from "./BottomModal"; import IconClose from "../icons/Close"; +import BottomModal from "./BottomModal"; import IconNano from "../icons/NanoS"; import { rgba } from "../colors"; import LText from "./LText"; @@ -120,13 +120,6 @@ const FirmwareUpdateBanner = () => { isOpened={showDrawer} onClose={onCloseDrawer} > - - - - { + const lastSeenDevice: DeviceModelInfo | null = useSelector( + lastSeenDeviceSelector, + ); + const hasConnectedDevice = useSelector(hasConnectedDeviceSelector); + const hasCompletedOnboarding: boolean = useSelector( + hasCompletedOnboardingSelector, + ); + const [showDrawer, setShowDrawer] = useState(false); + const [showBanner, setShowBanner] = useState(false); + const [version, setVersion] = useState(""); + + const { colors } = useTheme(); + const { t } = useTranslation(); + const useTouchable = useContext(ButtonUseTouchable); + + useEffect(() => { + async function getLatestFirmwareForDevice() { + const fw: + | FirmwareUpdateContext + | null + | undefined = await manager.getLatestFirmwareForDevice( + lastSeenDevice?.deviceInfo, + ); + + setShowBanner(Boolean(fw)); + setVersion(fw?.final?.name ?? ""); + } + + getLatestFirmwareForDevice(); + }, [lastSeenDevice, setShowBanner, setVersion]); + + const onPress = () => { + setShowDrawer(true); + }; + const onCloseDrawer = () => { + setShowDrawer(false); + }; + const onDismissBanner = () => { + setShowBanner(false); + }; + + return showBanner && hasConnectedDevice && hasCompletedOnboarding ? ( + <> + + + + + + + + + + ) : null} + + ); +}; + +export default GenericErrorView; diff --git a/src/components/GraphCard.tsx b/src/components/GraphCard.tsx new file mode 100644 index 0000000000..9e6a182b1f --- /dev/null +++ b/src/components/GraphCard.tsx @@ -0,0 +1,134 @@ +import React, { ReactNode, useCallback } from "react"; +import { TouchableOpacity, View } from "react-native"; +import { Currency, Unit } from "@ledgerhq/live-common/lib/types"; +import { + Portfolio, + ValueChange, +} from "@ledgerhq/live-common/lib/portfolio/v2/types"; +import { BoxedIcon, Flex, Text } from "@ledgerhq/native-ui"; +import { Trans } from "react-i18next"; +import { PieChartMedium } from "@ledgerhq/native-ui/assets/icons"; +import Delta from "./Delta"; +import { Item } from "./Graph/types"; +import TransactionsPendingConfirmationWarning from "./TransactionsPendingConfirmationWarning"; +import CurrencyUnitValue from "./CurrencyUnitValue"; +import Placeholder from "./Placeholder"; +import DiscreetModeButton from "./DiscreetModeButton"; +import { useNavigation } from "@react-navigation/native"; +import { NavigatorName } from "../const"; + +type Props = { + portfolio: Portfolio; + counterValueCurrency: Currency; + useCounterValue?: boolean; + renderTitle?: ({ counterValueUnit: Unit, item: Item }) => ReactNode; +}; + +export default function GraphCard({ + portfolio, + renderTitle, + counterValueCurrency, +}: Props) { + const { countervalueChange } = portfolio; + + const isAvailable = portfolio.balanceAvailable; + const balanceHistory = portfolio.balanceHistory; + + return ( + + + + ); +} + +function GraphCardHeader({ + unit, + valueChange, + renderTitle, + isLoading, + to, +}: { + isLoading: boolean; + valueChange: ValueChange; + unit: Unit; + to: Item; + renderTitle?: ({ counterValueUnit: Unit, item: Item }) => ReactNode; +}) { + const item = to; + const navigation = useNavigation(); + + const onPieChartButtonpress = useCallback(() => { + navigation.navigate(NavigatorName.Analytics); + }, [navigation]); + + return ( + + + + + + + + + + + {isLoading ? ( + + ) : renderTitle ? ( + renderTitle({ counterValueUnit: unit, item }) + ) : ( + + + + )} + + + + {isLoading ? ( + <> + + + + ) : ( + + + + + )} + + + + + + + + + ); +} diff --git a/src/components/HeaderBackImage.tsx b/src/components/HeaderBackImage.tsx new file mode 100644 index 0000000000..1d024f1925 --- /dev/null +++ b/src/components/HeaderBackImage.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { View, Platform, StyleSheet } from "react-native"; +import { ArrowLeftMedium } from "@ledgerhq/native-ui/assets/icons"; + +export default function HeaderBackImage() { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + root: { + marginLeft: Platform.OS === "ios" ? 0 : -13, + padding: 16, + }, +}); diff --git a/src/components/HeaderTitle.tsx b/src/components/HeaderTitle.tsx new file mode 100644 index 0000000000..669d7e0a3b --- /dev/null +++ b/src/components/HeaderTitle.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { TouchableWithoutFeedback } from "react-native"; +import { Text } from "@ledgerhq/native-ui"; +import { scrollToTop } from "../navigation/utils"; + +export default function HeaderTitle(props: any) { + return ( + + + + ); +} diff --git a/src/components/InfoModal.tsx b/src/components/InfoModal.tsx new file mode 100644 index 0000000000..842962a345 --- /dev/null +++ b/src/components/InfoModal.tsx @@ -0,0 +1,156 @@ +import React, { memo } from "react"; +import { StyleSheet, View } from "react-native"; +import { Trans } from "react-i18next"; + +import { useTheme } from "styled-components/native"; +import { Icons, IconBox, Flex, Button } from "@ledgerhq/native-ui"; +import BottomModal from "./BottomModal"; +import LText from "./LText"; +import IconArrowRight from "../icons/ArrowRight"; +import type { Props as ModalProps } from "./BottomModal"; + +type BulletItem = { + key: string; + val: React.ReactNode; +}; + +type InfoModalProps = ModalProps & { + id?: string; + title?: React.ReactNode; + desc?: React.ReactNode; + bullets?: BulletItem[]; + Icon?: React.ReactNode; + withCancel?: boolean; + onContinue?: () => void; + children?: React.ReactNode; + confirmLabel?: React.ReactNode; + confirmProps?: any; +}; + +const InfoModal = ({ + isOpened, + onClose, + id, + title, + desc, + bullets, + Icon = Icons.InfoMedium, + withCancel, + onContinue, + children, + confirmLabel, + confirmProps, + style, + containerStyle, +}: InfoModalProps) => ( + + + + {title ? ( + + {title} + + ) : null} + + {desc ? ( + + {desc} + + ) : null} + {bullets ? ( + + {bullets.map(b => ( + {b.val} + ))} + + ) : null} + + {children} + + + + + {withCancel ? ( + + ) : null} + + + +); + +function BulletLine({ children }: { children: any }) { + const { colors } = useTheme(); + return ( + + + + {children} + + + ); +} + +const styles = StyleSheet.create({ + modal: { + paddingHorizontal: 16, + paddingTop: 24, + alignItems: "center", + }, + modalTitle: { + marginVertical: 16, + fontSize: 14, + lineHeight: 21, + }, + modalDesc: { + textAlign: "center", + + marginBottom: 24, + }, + bulletsContainer: { + alignSelf: "flex-start", + }, + bulletLine: { + flexDirection: "row", + alignItems: "center", + marginBottom: 8, + }, + bulletLineText: { + marginLeft: 4, + textAlign: "left", + }, + childrenContainer: { + paddingTop: 24, + }, + footer: { + alignSelf: "stretch", + paddingTop: 24, + flexDirection: "row", + }, +}); + +export default memo(InfoModal); diff --git a/src/components/InvertTheme.tsx b/src/components/InvertTheme.tsx new file mode 100644 index 0000000000..ded040caf4 --- /dev/null +++ b/src/components/InvertTheme.tsx @@ -0,0 +1,22 @@ +import React, { useMemo } from "react"; +import { ThemeProvider, useTheme } from "styled-components/native"; +import { defaultTheme, palettes } from "@ledgerhq/native-ui/styles"; + +export default function InvertTheme({ + children, +}: { + children?: React.ReactNode; +}): React.ReactElement { + const { theme } = useTheme(); + const revertTheme = theme === "light" ? "dark" : "light"; + const newTheme = useMemo( + () => ({ + ...defaultTheme, + colors: { ...defaultTheme.colors, palette: palettes[revertTheme] }, + theme: revertTheme, + }), + [revertTheme], + ); + + return {children}; +} diff --git a/src/components/LText/index.tsx b/src/components/LText/index.tsx new file mode 100644 index 0000000000..7f6248bb3a --- /dev/null +++ b/src/components/LText/index.tsx @@ -0,0 +1,56 @@ +/* @flow */ +import React from "react"; +import { Text } from "@ledgerhq/native-ui"; +import getFontStyle from "./getFontStyle"; +import { FontWeightTypes } from "@ledgerhq/native-ui/components/Text/getTextStyle"; + +export { getFontStyle }; + +export type Opts = { + bold?: boolean; + semiBold?: boolean; + secondary?: boolean; + monospace?: boolean; + color?: string; + bg?: string; + children?: React.ReactNode; +}; + +export type Res = { + fontFamily: string; + fontWeight: + | "normal" + | "bold" + | "100" + | "200" + | "300" + | "400" + | "500" + | "600" + | "700" + | "800" + | "900"; +}; + +const inferFontWeight = ({ semiBold, bold }: Opts): FontWeightTypes => { + if (bold) { + return 'bold' + } else if (semiBold) { + return 'semibold' + } + return 'medium' +}; + +/** + * This component is just a proxy to the Text component defined in @ledgerhq/react-ui. + * It should only be used to map legacy props/logic from LLM to the new text component. + * + * @deprecated Please, prefer using the Text component from our design-system if possible. + */ +export default function LText({ color, children, semiBold, bold, ...props }: Opts) { + return ( + + {children} + + ); +} diff --git a/src/components/ModalBottomAction.tsx b/src/components/ModalBottomAction.tsx new file mode 100644 index 0000000000..bf437419bc --- /dev/null +++ b/src/components/ModalBottomAction.tsx @@ -0,0 +1,48 @@ +/* @flow */ +import React, { Component } from "react"; +import { Flex, Text } from "@ledgerhq/native-ui"; + +export default class ModalBottomAction extends Component<{ + icon?: any; + title?: any; + uppercase?: boolean; + description?: any; + footer: any; + shouldWrapDesc?: boolean; +}> { + render() { + const { + icon, + title, + uppercase, + description, + footer, + shouldWrapDesc = true, + } = this.props; + return ( + + {icon && {icon}} + {title ? ( + + {title} + + ) : null} + + {description && shouldWrapDesc ? ( + + {description} + + ) : ( + description + )} + {footer} + + + ); + } +} diff --git a/src/components/NavigationHeader.tsx b/src/components/NavigationHeader.tsx new file mode 100644 index 0000000000..3666e27f10 --- /dev/null +++ b/src/components/NavigationHeader.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { ArrowLeftMedium, CloseMedium } from "@ledgerhq/native-ui/assets/icons"; +import { Flex, Text, Link } from "@ledgerhq/native-ui"; +import { StackHeaderProps } from "@react-navigation/stack"; +import { getHeaderTitle } from "@react-navigation/elements"; +import { FlexBoxProps } from "@ledgerhq/native-ui/components/layout/Flex"; + +type NavigationHeaderProps = StackHeaderProps & { + containerProps?: FlexBoxProps; + hideBack?: boolean; +}; + +function NavigationHeader({ + navigation, + route, + options, + back, + hideBack, + containerProps, +}: NavigationHeaderProps) { + const { t } = useTranslation(); + const title = t(getHeaderTitle(options, route.name)); + return ( + + {back && !hideBack ? ( + + ) : ( + + )} + {title.length ? ( + + {title} + + ) : null} + { + navigation.getParent()?.goBack(); + }} + /> + + ); +} + +export default (props: NavigationHeaderProps) => ( + +); diff --git a/src/components/NavigationModalContainer.tsx b/src/components/NavigationModalContainer.tsx new file mode 100644 index 0000000000..3aa2dbe2dd --- /dev/null +++ b/src/components/NavigationModalContainer.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Pressable } from "react-native"; +import { Flex, ScrollContainer } from "@ledgerhq/native-ui"; +import { StackScreenProps } from "@react-navigation/stack"; +import styled from "styled-components/native"; +import type { FlexBoxProps } from "@ledgerhq/native-ui/components/layout/Flex"; +import { SafeAreaView } from "react-native-safe-area-context"; + +export const MIN_MODAL_HEIGHT = 30; + +const ScreenContainer = styled(Flex).attrs(p => ({ + edges: ["bottom"], + flex: 1, + p: p.p ?? 6, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, +}))``; +type Props = StackScreenProps<{}> & { + children: React.ReactNode, + contentContainerProps?: FlexBoxProps, + deadZoneProps?: FlexBoxProps, + backgroundColor?: string, + }; + +export default function NavigationModalContainer({ + navigation, + children, + contentContainerProps, + deadZoneProps, + backgroundColor = "palette.neutral.c00", +}: Props) { + return ( + + + { + navigation.canGoBack() && navigation.goBack(); + }} + /> + + + + + {children} + + + + ); +} diff --git a/src/components/NavigationOverlay.tsx b/src/components/NavigationOverlay.tsx new file mode 100644 index 0000000000..2e45148461 --- /dev/null +++ b/src/components/NavigationOverlay.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { Pressable, StyleSheet } from "react-native"; +import { useNavigation } from "@react-navigation/native"; +import styled from "styled-components/native"; + +const Container = styled(Pressable)` + background-color: ${p => p.theme.colors.constant.overlay}; +`; + +export default function NavigationOverlay() { + const navigation = useNavigation(); + + return ( + { + navigation.canGoBack() && navigation.goBack(); + }} + /> + ); +} diff --git a/src/components/NavigationScrollView.tsx b/src/components/NavigationScrollView.tsx new file mode 100644 index 0000000000..ba1b5354f2 --- /dev/null +++ b/src/components/NavigationScrollView.tsx @@ -0,0 +1,18 @@ +import { ScrollListContainer } from "@ledgerhq/native-ui"; +import React, { useRef } from "react"; +import { ScrollViewProps } from "react-native"; +import { useScrollToTop } from "../navigation/utils"; + +export default function NavigationScrollView({ + children, + ...scrollViewProps +}: ScrollViewProps) { + const ref = useRef(); + useScrollToTop(ref); + + return ( + + {children} + + ); +} diff --git a/src/components/Nft/NftCollectionRow.tsx b/src/components/Nft/NftCollectionRow.tsx new file mode 100644 index 0000000000..7c142f0319 --- /dev/null +++ b/src/components/Nft/NftCollectionRow.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import { StyleSheet } from "react-native"; +import { RectButton } from "react-native-gesture-handler"; +import { + useNftMetadata, + CollectionWithNFT, +} from "@ledgerhq/live-common/lib/nft"; +import { Flex, Text } from "@ledgerhq/native-ui"; +import { useTheme } from "styled-components/native"; +import Skeleton from "../Skeleton"; +import NftImage from "./NftImage"; + +type Props = { + collection: CollectionWithNFT; + onCollectionPress: () => void; +}; + +function NftCollectionRow({ collection, onCollectionPress }: Props) { + const { colors } = useTheme(); + const { contract, nfts } = collection; + const { status, metadata } = useNftMetadata(contract, nfts[0].tokenId); + const loading = status === "loading"; + + return ( + + + + + + + {metadata?.tokenName || collection.contract} + + + + + {collection.nfts.length} + + + + ); +} + +export default NftCollectionRow; + +const styles = StyleSheet.create({ + container: { + borderRadius: 4, + }, + collectionNameSkeleton: { + height: 8, + width: 113, + borderRadius: 4, + }, + collectionImage: { + borderRadius: 4, + width: 36, + aspectRatio: 1, + overflow: "hidden", + }, +}); diff --git a/src/components/Nft/NftLinksPanel.js b/src/components/Nft/NftLinksPanel.js index 6934c9fb97..384a178922 100644 --- a/src/components/Nft/NftLinksPanel.js +++ b/src/components/Nft/NftLinksPanel.js @@ -10,7 +10,6 @@ import ExternalLinkIcon from "../../icons/ExternalLink"; import OpenSeaIcon from "../../icons/OpenSea"; import RaribleIcon from "../../icons/Rarible"; import GlobeIcon from "../../icons/Globe"; -import CloseIcon from "../../icons/Close"; import BottomModal from "../BottomModal"; import { rgba } from "../../colors"; import LText from "../LText"; @@ -66,10 +65,6 @@ const NftLinksPanel = ({ links, isOpen, onClose }: Props) => { isOpened={isOpen} onClose={onClose} > - - - - {!links.opensea ? null : ( ({ + height: "64px", + flexDirection: "row", + alignItems: "center", + px: 0, + py: 6, +}))<{ isLast?: boolean }>``; + +const Wrapper = styled(Flex).attrs(p => ({ + flex: 1, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginLeft: 4, + marginRight: 0, + opacity: p.isOptimistic ? 0.5 : 1, +}))<{ isOptimistic?: boolean }>``; + +const SpinnerContainer = styled(Box).attrs({ + height: 14, + mr: 2, + justifyContent: "center", +})``; + +const BodyLeftContainer = styled(Flex).attrs({ + flexDirection: "column", + justifyContent: "flex-start", + alignItems: "flex-start", + flex: 1, +})``; + +const BodyRightContainer = styled(Flex).attrs({ + flexDirection: "column", + justifyContent: "flex-start", + alignItems: "flex-end", + flexShrink: 0, + pl: 3, +})``; + +type Props = { + operation: Operation; + parentAccount: Account | undefined | null; + account: AccountLike; + multipleAccounts?: boolean; + isLast: boolean; + isSubOperation?: boolean; +}; + +const placeholderProps = { + width: 40, + containerHeight: 20, +}; + +export default function OperationRow({ + account, + parentAccount, + operation, + isSubOperation, + multipleAccounts, + isLast, +}: Props) { + const navigation = useNavigation(); + + const goToOperationDetails = debounce(() => { + const params = [ + ScreenName.OperationDetails, + { + accountId: account.id, + parentId: parentAccount && parentAccount.id, + operation, // FIXME we should pass a operationId instead because data can changes over time. + isSubOperation, + key: operation.id, + }, + ]; + + /** if suboperation push to stack navigation else we simply navigate */ + if (isSubOperation) navigation.push(...params); + else navigation.navigate(...params); + }, 300); + + const renderAmountCellExtra = useCallback(() => { + const mainAccount = getMainAccount(account, parentAccount); + const currency = getAccountCurrency(account); + const unit = getAccountUnit(account); + const specific = mainAccount.currency.family + ? perFamilyOperationDetails[mainAccount.currency.family] + : null; + + const SpecificAmountCell = + specific && specific.amountCell + ? specific.amountCell[operation.type] + : null; + + return SpecificAmountCell ? ( + + ) : null; + }, [account, parentAccount, operation]); + + const amount = getOperationAmountNumber(operation); + const valueColor = amount.isNegative() ? "neutral.c100" : "success.c100"; + const currency = getAccountCurrency(account); + const unit = getAccountUnit(account); + + const text = ; + const isOptimistic = operation.blockHeight === null; + const spinner = ( + + + + ); + + return ( + + + + + + + + {multipleAccounts ? getAccountName(account) : text} + + + {isOptimistic ? ( + + {spinner} + + + + + ) : ( + + {text} + + )} + + + {renderAmountCellExtra()} + + {amount.isZero() ? null : ( + + + + + + + + + )} + + + ); +} diff --git a/src/components/PasswordInput.tsx b/src/components/PasswordInput.tsx new file mode 100644 index 0000000000..0b882fcd1a --- /dev/null +++ b/src/components/PasswordInput.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useState, useCallback, useRef } from "react"; +import { View, StyleSheet, TextInput } from "react-native"; +import Icon from "react-native-vector-icons/dist/Feather"; +import Touchable from "./Touchable"; +import { getFontStyle } from "./LText"; +import { withTheme } from "../colors"; + +type Props = { + secureTextEntry: boolean; + onChange: (value: string) => void; + onSubmit: () => void; + toggleSecureTextEntry: () => void; + placeholder: string; + autoFocus?: boolean; + inline?: boolean; + onFocus?: any; + onBlur?: any; + error?: Error; + password?: string; + colors: any; +}; + +const PasswordInput = ({ + autoFocus, + error, + secureTextEntry, + onChange, + onSubmit, + onFocus, + onBlur, + toggleSecureTextEntry, + placeholder, + inline, + password, + colors, +}: Props) => { + const [isFocused, setIsFocused] = useState(false); + const ref = useRef(); + + useEffect(() => { + if (autoFocus) { + ref.current?.focus(); + } + }, [autoFocus]); + + const wrappedOnFocus = useCallback(() => { + setIsFocused(true); + onFocus && onFocus(); + }, [onFocus]); + + const wrappedOnBlur = useCallback(() => { + setIsFocused(false); + onBlur && onBlur(); + }, [onBlur]); + + let borderColorOverride = {}; + if (!inline && isFocused) { + if (error) { + borderColorOverride = { borderColor: colors.alert }; + } else { + borderColorOverride = { borderColor: colors.live }; + } + } + + return ( + + + {secureTextEntry ? ( + + + + ) : ( + + + + )} + + ); +}; + +export default withTheme(PasswordInput); +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + borderRadius: 4, + marginBottom: 16, + }, + nonInlineContainer: { + borderWidth: 1, + }, + inlineTextInput: { + fontSize: 20, + }, + input: { + fontSize: 16, + paddingHorizontal: 16, + height: 48, + flex: 1, + }, + iconInput: { + justifyContent: "center", + marginRight: 16, + }, +}); diff --git a/src/components/Pills.tsx b/src/components/Pills.tsx new file mode 100644 index 0000000000..a2559a1db6 --- /dev/null +++ b/src/components/Pills.tsx @@ -0,0 +1,31 @@ +import React, { memo } from "react"; +import { GraphTabs } from "@ledgerhq/native-ui"; + +type Item = { + key: string; + label: string; + value?: any; +}; + +type Props = { + value: string; + items: Item[]; + onChange: (value: Item) => void; + isDisabled?: boolean; +}; + +function Pills({ items, value, onChange, isDisabled }: Props) { + const activeIndex = items.findIndex(item => item.key === value); + return ( + item.label)} + onChange={activeIndex => onChange(items[activeIndex])} + disabled={isDisabled} + size={"small"} + activeBg={"neutral.c40"} + /> + ); +} + +export default memo(Pills); diff --git a/src/components/RecipientInput.tsx b/src/components/RecipientInput.tsx new file mode 100644 index 0000000000..be38e61636 --- /dev/null +++ b/src/components/RecipientInput.tsx @@ -0,0 +1,53 @@ +import { Flex } from "@ledgerhq/native-ui"; +import { PasteMedium } from "@ledgerhq/native-ui/assets/icons"; +import React, { ForwardedRef } from "react"; +import { useTranslation } from "react-i18next"; +import { TextInput as BaseTextInput } from "react-native"; +import { TouchableOpacity } from "react-native-gesture-handler"; +import styled from "styled-components/native"; + +import TextInput, { Props as TextInputProps } from "./TextInput"; + +const PasteButton = styled(TouchableOpacity).attrs(() => ({ + activeOpacity: 0.6, +}))` + background-color: ${p => p.theme.colors.neutral.c100}; + display: flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 38px; + border-width: 0; +`; + +const PasteIcon = styled(PasteMedium).attrs(p => ({ + color: p.theme.colors.neutral.c00, + size: 20, +}))``; + +type Props = TextInputProps & { + ref?: ForwardedRef; + onPaste?: () => void; +}; + +const RecipientInput = ({ ref, onPaste, ...props }: Props) => { + const { t } = useTranslation(); + + return ( + + + + + + } + {...props} + /> + ); +}; + +export default RecipientInput; diff --git a/src/components/RequireTerms.js b/src/components/RequireTerms.js index 672cea34e2..66ea74bdc2 100644 --- a/src/components/RequireTerms.js +++ b/src/components/RequireTerms.js @@ -12,7 +12,7 @@ import { import { useTheme } from "@react-navigation/native"; import { useTerms, useTermsAccept, url } from "../logic/terms"; import getWindowDimensions from "../logic/getWindowDimensions"; -import { useLocale } from "../context/Locale"; +import { useTranslationLocale } from "../context/Locale"; import LText from "./LText"; import SafeMarkdown from "./SafeMarkdown"; import Button from "./Button"; @@ -64,7 +64,7 @@ const styles = StyleSheet.create({ const RequireTermsModal = () => { const { colors } = useTheme(); - const { locale } = useLocale(); + const { locale } = useTranslationLocale(); const [markdown, error, retry] = useTerms(locale); const [accepted, accept] = useTermsAccept(); const [toggle, setToggle] = useState(false); @@ -147,7 +147,7 @@ export const TermModals = ({ close: () => void, }) => { const { colors } = useTheme(); - const { locale } = useLocale(); + const { locale } = useTranslationLocale(); const [markdown, error, retry] = useTerms(locale); const height = getWindowDimensions().height - 320; diff --git a/src/components/RequireTerms.tsx b/src/components/RequireTerms.tsx new file mode 100644 index 0000000000..07abea3d65 --- /dev/null +++ b/src/components/RequireTerms.tsx @@ -0,0 +1,196 @@ +import React, { useCallback, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { + StyleSheet, + View, + ScrollView, + Linking, + ActivityIndicator, +} from "react-native"; +import { useTheme } from "@react-navigation/native"; +import { GraphGrowAltMedium } from "@ledgerhq/native-ui/assets/icons"; +import { BottomDrawer } from "@ledgerhq/native-ui"; +import { useTerms, useTermsAccept, url } from "../logic/terms"; +import getWindowDimensions from "../logic/getWindowDimensions"; +import { useTranslationLocale } from "../context/Locale"; +import LText from "./LText"; +import SafeMarkdown from "./SafeMarkdown"; +import Button from "./Button"; +import BottomModal from "./BottomModal"; +import ExternalLink from "./ExternalLink"; +import CheckBox from "./CheckBox"; +import Touchable from "./Touchable"; +import GenericErrorView from "./GenericErrorView"; +import RetryButton from "./RetryButton"; + +const styles = StyleSheet.create({ + modal: {}, + root: { + paddingTop: 0, + padding: 16, + }, + header: { + paddingVertical: 16, + }, + title: { + textAlign: "center", + + fontSize: 16, + }, + body: {}, + switchRow: { + flexDirection: "row", + alignItems: "center", + marginVertical: 20, + }, + switchLabel: { + marginLeft: 8, + + fontSize: 13, + paddingRight: 16, + }, + footer: { + flexDirection: "column", + justifyContent: "space-between", + borderTopWidth: 1, + }, + footerClose: { + marginTop: 16, + }, + retryButton: { + marginTop: 16, + }, +}); + +const RequireTermsModal = () => { + const { colors } = useTheme(); + const { locale } = useTranslationLocale(); + const [markdown, error, retry] = useTerms(locale); + const [accepted, accept] = useTermsAccept(); + const [toggle, setToggle] = useState(false); + const onSwitch = useCallback(() => { + setToggle(!toggle); + }, [toggle]); + + const height = getWindowDimensions().height - 320; + + return ( + + + + + + + + + + {markdown ? ( + + ) : error ? ( + + + } + onPress={() => Linking.openURL(url)} + event="OpenTerms" + /> + + + + + ) : ( + + )} + + + + + + + + + + + + + ); +}; diff --git a/src/components/RequiresBLE/BluetoothDisabled.tsx b/src/components/RequiresBLE/BluetoothDisabled.tsx new file mode 100644 index 0000000000..78694fa357 --- /dev/null +++ b/src/components/RequiresBLE/BluetoothDisabled.tsx @@ -0,0 +1,34 @@ +import React, { memo } from "react"; +import { Trans } from "react-i18next"; +import { IconBox, Text } from "@ledgerhq/native-ui"; +import { BluetoothMedium } from "@ledgerhq/native-ui/assets/icons"; +import styled from "styled-components/native"; +import { deviceNames } from "../../wording"; + +const SafeAreaContainer = styled.SafeAreaView` + flex: 1; + align-items: center; + justify-content: center; + background-color: ${p => p.theme.colors.background.main}; +`; + +function BluetoothDisabled() { + return ( + + + + + + + + + + ); +} + +export default memo<{}>(BluetoothDisabled); diff --git a/src/components/RootNavigator/AccountSettingsNavigator.js b/src/components/RootNavigator/AccountSettingsNavigator.js index 6a49fc95b8..90e545bc60 100644 --- a/src/components/RootNavigator/AccountSettingsNavigator.js +++ b/src/components/RootNavigator/AccountSettingsNavigator.js @@ -31,7 +31,7 @@ export default function AccountSettingsNavigator() { component={AccountSettingsMain} options={{ title: t("account.settings.header"), - headerRight: closableNavconfig.headerRight, + headerRight: null, }} /> getStackNavigatorConfig(colors), [ + colors, + ]); + return ( + + + , + headerRight: () => , + }} + /> + , + }} + /> + , + }} + /> + + ); +} + +const Stack = createStackNavigator(); diff --git a/src/components/RootNavigator/AddAccountsNavigator.js b/src/components/RootNavigator/AddAccountsNavigator.js index 2d6408e4d5..746534cdec 100644 --- a/src/components/RootNavigator/AddAccountsNavigator.js +++ b/src/components/RootNavigator/AddAccountsNavigator.js @@ -47,7 +47,6 @@ export default function AddAccountsNavigator({ route }: { route: Route }) { screenOptions={{ ...stackNavConfig, headerRight: () => , - headerMode: "float", }} > getLineTabNavigatorConfig(colors), [ + colors, + ]); + + // Fixme Typescript: Update react-native-tab-view to 3.1.1 to remove Tab.navigator ts error + return ( + + ( + + {t("analytics.allocation.title")} + + ), + }} + /> + ( + + {t("analytics.operations.title")} + + ), + }} + /> + + ); +} diff --git a/src/components/RootNavigator/BaseNavigator.js b/src/components/RootNavigator/BaseNavigator.js index af12d0334e..242956ee96 100644 --- a/src/components/RootNavigator/BaseNavigator.js +++ b/src/components/RootNavigator/BaseNavigator.js @@ -7,6 +7,7 @@ import { } from "@react-navigation/stack"; import { useTranslation } from "react-i18next"; import { useTheme } from "@react-navigation/native"; +import { Flex, Icons } from "@ledgerhq/native-ui"; import { ScreenName, NavigatorName } from "../../const"; import * as families from "../../families"; import OperationDetails, { @@ -49,6 +50,8 @@ import LendingEnableFlowNavigator from "./LendingEnableFlowNavigator"; import LendingSupplyFlowNavigator from "./LendingSupplyFlowNavigator"; import LendingWithdrawFlowNavigator from "./LendingWithdrawFlowNavigator"; import NotificationCenterNavigator from "./NotificationCenterNavigator"; +// eslint-disable-next-line import/no-unresolved +import AnalyticsNavigator from "./AnalyticsNavigator"; import NftNavigator from "./NftNavigator"; import { getStackNavigatorConfig } from "../../navigation/navigatorConfig"; import Account from "../../screens/Account"; @@ -69,6 +72,10 @@ import SwapFormSelectCurrency from "../../screens/Swap/FormSelection/SelectCurre import SwapFormSelectFees from "../../screens/Swap/FormSelection/SelectFeesScreen"; import SwapFormSelectProviderRate from "../../screens/Swap/FormSelection/SelectProviderRateScreen"; +import BuyDeviceScreen from "../../screens/BuyDeviceScreen"; +import { readOnlyModeEnabledSelector } from "../../reducers/settings"; +import { useSelector } from "react-redux"; + export default function BaseNavigator() { const { t } = useTranslation(); const { colors } = useTheme(); @@ -76,11 +83,13 @@ export default function BaseNavigator() { () => getStackNavigatorConfig(colors, true), [colors], ); + const readOnlyModeEnabled = useSelector(readOnlyModeEnabledSelector); + return ( + ({ + headerBackImage: () => ( + + + + ), headerStyle: styles.headerNoShadow, title: route.params.name, })} @@ -273,18 +292,32 @@ export default function BaseNavigator() { /> + , headerRight: null, @@ -400,7 +444,7 @@ export default function BaseNavigator() { /> ({ headerLeft: () => ( @@ -481,28 +525,20 @@ export default function BaseNavigator() { /> , - tabBarTestID: "TabBarManager", - headerShown: false, - }} - listeners={({ navigation }) => ({ - tabPress: e => { - e.preventDefault(); - // NB The default behaviour is not reset route params, leading to always having the same - // search query or preselected tab after the first time (ie from Swap/Sell) - // https://github.com/react-navigation/react-navigation/issues/6674#issuecomment-562813152 - navigation.navigate(NavigatorName.Manager, { - screen: ScreenName.Manager, - params: { - tab: undefined, - searchQuery: undefined, - updateModalOpened: undefined, + {...(readOnlyModeEnabled + ? { + component: BuyDeviceScreen, + options: { + ...TransitionPresets.ModalTransition, + headerShown: false, }, - }); - }, - })} + } + : { + component: ManagerNavigator, + options: { + headerShown: false, + }, + })} /> {Object.keys(families).map(name => { const { component, options } = families[name]; diff --git a/src/components/RootNavigator/BaseNavigator.tsx b/src/components/RootNavigator/BaseNavigator.tsx new file mode 100644 index 0000000000..c97b664133 --- /dev/null +++ b/src/components/RootNavigator/BaseNavigator.tsx @@ -0,0 +1,559 @@ +import React, { useMemo } from "react"; +import { + createStackNavigator, + CardStyleInterpolators, + TransitionPresets, +} from "@react-navigation/stack"; +import { useTranslation } from "react-i18next"; +import { Flex, Icons } from "@ledgerhq/native-ui"; +import { useSelector } from "react-redux"; +import { useTheme } from "styled-components/native"; +import { ScreenName, NavigatorName } from "../../const"; +import * as families from "../../families"; +import OperationDetails, { + BackButton, + CloseButton, +} from "../../screens/OperationDetails"; +import PairDevices from "../../screens/PairDevices"; +import EditDeviceName from "../../screens/EditDeviceName"; +import Distribution from "../../screens/Distribution"; +import Asset, { HeaderTitle } from "../../screens/Asset"; +import ScanRecipient from "../../screens/SendFunds/ScanRecipient"; +import WalletConnectScan from "../../screens/WalletConnect/Scan"; +import WalletConnectConnect from "../../screens/WalletConnect/Connect"; +import WalletConnectDeeplinkingSelectAccount from "../../screens/WalletConnect/DeeplinkingSelectAccount"; +import FallbackCameraSend from "../FallbackCamera/FallbackCameraSend"; +import Main from "./MainNavigator"; +import { ErrorHeaderInfo } from "./BaseOnboardingNavigator"; +import SettingsNavigator from "./SettingsNavigator"; +import ReceiveFundsNavigator from "./ReceiveFundsNavigator"; +import SendFundsNavigator from "./SendFundsNavigator"; +import SignMessageNavigator from "./SignMessageNavigator"; +import SignTransactionNavigator from "./SignTransactionNavigator"; +import FreezeNavigator from "./FreezeNavigator"; +import UnfreezeNavigator from "./UnfreezeNavigator"; +import ClaimRewardsNavigator from "./ClaimRewardsNavigator"; +import AddAccountsNavigator from "./AddAccountsNavigator"; +import ExchangeBuyFlowNavigator from "./ExchangeBuyFlowNavigator"; +import ExchangeSellFlowNavigator from "./ExchangeSellFlowNavigator"; +import ExchangeNavigator from "./ExchangeNavigator"; +import FirmwareUpdateNavigator from "./FirmwareUpdateNavigator"; +import AccountSettingsNavigator from "./AccountSettingsNavigator"; +import ImportAccountsNavigator from "./ImportAccountsNavigator"; +import PasswordAddFlowNavigator from "./PasswordAddFlowNavigator"; +import PasswordModifyFlowNavigator from "./PasswordModifyFlowNavigator"; +import MigrateAccountsFlowNavigator from "./MigrateAccountsFlowNavigator"; +import SwapNavigator from "./SwapNavigator"; +import LendingNavigator from "./LendingNavigator"; +import LendingInfoNavigator from "./LendingInfoNavigator"; +import LendingEnableFlowNavigator from "./LendingEnableFlowNavigator"; +import LendingSupplyFlowNavigator from "./LendingSupplyFlowNavigator"; +import LendingWithdrawFlowNavigator from "./LendingWithdrawFlowNavigator"; +import NotificationCenterNavigator from "./NotificationCenterNavigator"; +import AnalyticsNavigator from "./AnalyticsNavigator"; +import NftNavigator from "./NftNavigator"; +import { getStackNavigatorConfig } from "../../navigation/navigatorConfig"; +import Account from "../../screens/Account"; +import TransparentHeaderNavigationOptions from "../../navigation/TransparentHeaderNavigationOptions"; +import styles from "../../navigation/styles"; +import HeaderRightClose from "../HeaderRightClose"; +import StepHeader from "../StepHeader"; +import AccountHeaderTitle from "../../screens/Account/AccountHeaderTitle"; +import AccountHeaderRight from "../../screens/Account/AccountHeaderRight"; +import PortfolioHistory from "../../screens/Portfolio/PortfolioHistory"; +import RequestAccountNavigator from "./RequestAccountNavigator"; +import VerifyAccount from "../../screens/VerifyAccount"; +import PlatformApp from "../../screens/Platform/App"; +import AccountsNavigator from "./AccountsNavigator"; + +import SwapFormSelectAccount from "../../screens/Swap/FormSelection/SelectAccountScreen"; +import SwapFormSelectCurrency from "../../screens/Swap/FormSelection/SelectCurrencyScreen"; +import SwapFormSelectFees from "../../screens/Swap/FormSelection/SelectFeesScreen"; +import SwapFormSelectProviderRate from "../../screens/Swap/FormSelection/SelectProviderRateScreen"; + +import BuyDeviceScreen from "../../screens/BuyDeviceScreen"; +import { readOnlyModeEnabledSelector } from "../../reducers/settings"; +import useFeature from "@ledgerhq/live-common/lib/featureFlags/useFeature"; +import Learn from "../../screens/Learn"; +import ManagerMain from "../../screens/Manager/Manager"; + +export default function BaseNavigator() { + const { t } = useTranslation(); + const { colors } = useTheme(); + const stackNavigationConfig = useMemo( + () => getStackNavigatorConfig(colors, true), + [colors], + ); + const readOnlyModeEnabled = useSelector(readOnlyModeEnabledSelector); + const learn = useFeature("learn"); + + return ( + + + + + + + ({ + headerBackImage: () => ( + + + + ), + headerStyle: styles.headerNoShadow, + title: route.params.name, + })} + /> + {learn?.enabled ? ( + + ) : null} + + + + ({ + headerTitle: () => ( + + ), + headerRight: null, + })} + /> + , + headerRight: null, + }} + /> + ( + + ), + headerRight: null, + }} + /> + ( + + ), + headerRight: null, + }} + /> + + + + + + + + + + ({ + beforeRemove: () => { + /** + react-navigation workaround try to fetch params from current route params + or fallback to child navigator route params + since this listener is on top of another navigator + */ + const onError = + route.params?.onError || route.params?.params?.onError; + // @TODO replace with correct error + if (onError && typeof onError === "function") + onError( + route.params.error || + new Error("Request account interrupted by user"), + ); + }, + })} + /> + ({ + beforeRemove: () => { + const onClose = + route.params?.onClose || route.params?.params?.onClose; + if (onClose && typeof onClose === "function") { + onClose(); + } + }, + })} + /> + + + + + { + if (route.params?.isSubOperation) { + return { + headerTitle: () => ( + + ), + headerLeft: () => , + headerRight: () => , + }; + } + + return { + headerTitle: () => ( + + ), + headerLeft: () => , + headerRight: null, + }; + }} + /> + + + ({ + title: null, + headerRight: () => ( + + ), + headerShown: true, + headerStyle: styles.headerNoShadow, + })} + /> + + + + + + + , + headerRight: null, + }} + /> + + ({ + headerLeft: () => ( + + ), + headerTitle: () => , + headerRight: () => , + })} + /> + ( + + ), + headerLeft: null, + }} + /> + ( + + ), + headerLeft: null, + }} + /> + , + headerLeft: null, + }} + /> + + + ({ + title: t("notificationCenter.title"), + headerLeft: null, + headerRight: () => , + cardStyleInterpolator: CardStyleInterpolators.forVerticalIOS, + })} + /> + ({ + title: null, + headerRight: null, + headerLeft: () => , + })} + /> + + + {Object.keys(families).map(name => { + const { component, options } = families[name]; + return ( + + ); + })} + + ); +} + +const Stack = createStackNavigator(); diff --git a/src/components/RootNavigator/CustomBlockRouterNavigator.tsx b/src/components/RootNavigator/CustomBlockRouterNavigator.tsx new file mode 100644 index 0000000000..4d3d139fa2 --- /dev/null +++ b/src/components/RootNavigator/CustomBlockRouterNavigator.tsx @@ -0,0 +1,47 @@ +// @flow +import { useEffect, useState } from "react"; +import { BehaviorSubject } from "rxjs"; + +export const lockSubject = new BehaviorSubject(false); + +export function useIsNavLocked(): boolean { + const [isLocked, setIsLocked] = useState(false); + + useEffect(() => { + const subscription = lockSubject.subscribe(val => { + setIsLocked(val); + }); + + return () => { + subscription.unsubscribe(); + }; + }, []); + + return isLocked; +} + +/** use Effect to trigger lock navigation updates and callback to retrieve catched navigation actions */ +export const useLockNavigation = ( + when: boolean, + callback: (...args: any[]) => void = () => {}, + navigation: any, +) => { + useEffect(() => { + lockSubject.next(when); + navigation.addListener("beforeRemove", (e: any) => { + if (!when) { + // If we don't have unsaved changes, then we don't need to do anything + return; + } + + // Prevent default behavior of leaving the screen + e.preventDefault(); + + callback(e.data.action); + }); + + return () => { + navigation.removeListener("beforeRemove"); + }; + }, [callback, navigation, when]); +}; diff --git a/src/components/RootNavigator/PlatformNavigator.js b/src/components/RootNavigator/DiscoverNavigator.ios.tsx similarity index 78% rename from src/components/RootNavigator/PlatformNavigator.js rename to src/components/RootNavigator/DiscoverNavigator.ios.tsx index a1515bd4e3..2fc4ed69ff 100644 --- a/src/components/RootNavigator/PlatformNavigator.js +++ b/src/components/RootNavigator/DiscoverNavigator.ios.tsx @@ -4,20 +4,21 @@ import React, { useMemo } from "react"; import { createStackNavigator } from "@react-navigation/stack"; import { useTheme } from "@react-navigation/native"; import { ScreenName } from "../../const"; -import PlatformCatalog from "../../screens/Platform"; import { getStackNavigatorConfig } from "../../navigation/navigatorConfig"; +import Discover from "../../screens/Discover"; -export default function PlatformNavigator() { +export default function DiscoverNavigator() { const { colors } = useTheme(); const stackNavigationConfig = useMemo( () => getStackNavigatorConfig(colors, true), [colors], ); + return ( getStackNavigatorConfig(colors, true), + [colors], + ); + const learn = useFeature("learn"); + + return ( + + + + + ); +} + +const Stack = createStackNavigator(); diff --git a/src/components/RootNavigator/ExchangeBuyFlowNavigator.js b/src/components/RootNavigator/ExchangeBuyFlowNavigator.js index 9a09c96a63..c589f700af 100644 --- a/src/components/RootNavigator/ExchangeBuyFlowNavigator.js +++ b/src/components/RootNavigator/ExchangeBuyFlowNavigator.js @@ -23,7 +23,6 @@ export default function ExchangeNavigator() { screenOptions={{ ...stackNavigationConfig, headerRight: () => , - headerMode: "float", }} > getLineTabNavigatorConfig(colors), [ + colors, + ]); + return ( + + ( + + {t("exchange.buy.tabTitle")} + + ), + }} + /> + ( + + {t("exchange.sell.tabTitle")} + + ), + }} + /> + + ); +} + +const Tab = createMaterialTopTabNavigator(); diff --git a/src/components/RootNavigator/ExchangeSellFlowNavigator.js b/src/components/RootNavigator/ExchangeSellFlowNavigator.js index 43c2a92e38..44c3fd3a31 100644 --- a/src/components/RootNavigator/ExchangeSellFlowNavigator.js +++ b/src/components/RootNavigator/ExchangeSellFlowNavigator.js @@ -23,7 +23,6 @@ export default function ExchangeNavigator() { screenOptions={{ ...stackNavigationConfig, headerRight: () => , - headerMode: "float", }} > , headerLeft: null, + headerTitleStyle: { color: "#fff" }, }} /> , }} /> @@ -47,7 +48,7 @@ export default function ImportAccountsNavigator() { name={ScreenName.FallBackCameraScreen} component={FallBackCameraScreen} options={{ - title: t("account.import.fallback.header"), + headerTitle: t("account.import.fallback.header"), }} /> diff --git a/src/components/RootNavigator/ImportAccountsNavigator.tsx b/src/components/RootNavigator/ImportAccountsNavigator.tsx new file mode 100644 index 0000000000..e895a3f41a --- /dev/null +++ b/src/components/RootNavigator/ImportAccountsNavigator.tsx @@ -0,0 +1,68 @@ +// @flow +import React, { useMemo } from "react"; +import { createStackNavigator } from "@react-navigation/stack"; +import { useTranslation } from "react-i18next"; +import { Text } from "@ledgerhq/native-ui"; +import { useTheme } from "styled-components/native"; +import { ScreenName } from "../../const"; +import ScanAccounts from "../../screens/ImportAccounts/Scan"; +import DisplayResult, { + BackButton, +} from "../../screens/ImportAccounts/DisplayResult"; +import FallBackCameraScreen from "../../screens/ImportAccounts/FallBackCameraScreen"; +import { getStackNavigatorConfig } from "../../navigation/navigatorConfig"; +import TransparentHeaderNavigationOptions from "../../navigation/TransparentHeaderNavigationOptions"; +import HeaderRightClose from "../HeaderRightClose"; + +export default function ImportAccountsNavigator() { + const { t } = useTranslation(); + const { colors } = useTheme(); + const stackNavigationConfig = useMemo( + () => getStackNavigatorConfig(colors, true), + [colors], + ); + return ( + + ( + + {t("account.import.scan.title")} + + ), + headerRight: props => , + headerLeft: null, + }} + /> + + {t("account.import.result.title")} + + ), + headerLeft: () => , + }} + /> + + {t("account.import.fallback.header")} + + ), + }} + /> + + ); +} + +const Stack = createStackNavigator(); diff --git a/src/components/RootNavigator/LendingInfoNavigator.js b/src/components/RootNavigator/LendingInfoNavigator.js index 48dcc7d71c..713c871616 100644 --- a/src/components/RootNavigator/LendingInfoNavigator.js +++ b/src/components/RootNavigator/LendingInfoNavigator.js @@ -44,7 +44,6 @@ export default function LendingInfoNavigator() { headerLeft: null, headerRight: () => , gestureEnabled: false, - headerMode: "float", })} > + , + }} + /> + ( + + ), + }} + listeners={({ navigation }) => ({ + tabPress: (e: any) => { + e.preventDefault(); + // NB The default behaviour is not reset route params, leading to always having the same + // search query or preselected tab after the first time (ie from Swap/Sell) + // https://github.com/react-navigation/react-navigation/issues/6674#issuecomment-562813152 + navigation.navigate(NavigatorName.Market, { + screen: ScreenName.MarketList, + }); + }, + })} + /> + + , + }} + /> + ( + + ), + }} + listeners={({ navigation }) => ({ + tabPress: (e: any) => { + e.preventDefault(); + // NB The default behaviour is not reset route params, leading to always having the same + // search query or preselected tab after the first time (ie from Swap/Sell) + // https://github.com/react-navigation/react-navigation/issues/6674#issuecomment-562813152 + navigation.navigate(NavigatorName.Discover, { + screen: ScreenName.DiscoverScreen, + }); + }, + })} + /> + , + tabBarTestID: "TabBarManager", + }} + listeners={({ navigation }) => ({ + tabPress: (e: any) => { + e.preventDefault(); + if (readOnlyModeEnabled) { + // NB The default behaviour is not reset route params, leading to always having the same + // search query or preselected tab after the first time (ie from Swap/Sell) + // https://github.com/react-navigation/react-navigation/issues/6674#issuecomment-562813152 + navigation.navigate(ScreenName.BuyDeviceScreen, { + from: NavigatorName.Manager, + }); + } else { + // NB The default behaviour is not reset route params, leading to always having the same + // search query or preselected tab after the first time (ie from Swap/Sell) + // https://github.com/react-navigation/react-navigation/issues/6674#issuecomment-562813152 + navigation.navigate(NavigatorName.Manager, { + screen: ScreenName.Manager, + params: { + tab: undefined, + searchQuery: undefined, + updateModalOpened: undefined, + }, + }); + } + }, + })} + /> + + ); +} diff --git a/src/components/RootNavigator/ManagerNavigator.tsx b/src/components/RootNavigator/ManagerNavigator.tsx new file mode 100644 index 0000000000..f1e277b09c --- /dev/null +++ b/src/components/RootNavigator/ManagerNavigator.tsx @@ -0,0 +1,106 @@ +// @flow +import React, { useMemo } from "react"; +import { TouchableOpacity } from "react-native"; +import styled, { useTheme } from "styled-components/native"; +import { createStackNavigator } from "@react-navigation/stack"; +import { useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; +import { NanoFoldedMedium } from "@ledgerhq/native-ui/assets/icons"; +import { ScreenName } from "../../const"; +import { hasAvailableUpdateSelector } from "../../reducers/settings"; +import Manager from "../../screens/Manager"; +import { getStackNavigatorConfig } from "../../navigation/navigatorConfig"; +import styles from "../../navigation/styles"; +import ReadOnlyTab from "../ReadOnlyTab"; +import NanoXIcon from "../../icons/TabNanoX"; +import { useIsNavLocked } from "./CustomBlockRouterNavigator"; + +import { Box, Icons, Flex } from "@ledgerhq/native-ui"; + +const BadgeContainer = styled(Flex).attrs({ + position: "absolute", + top: -1, + right: -1, + width: 14, + height: 14, + borderRadius: 7, + borderWidth: 3, +})``; + +const Badge = () => { + const { colors } = useTheme(); + return ( + + ); +}; + +const ManagerIconWithUpate = ({ + color, + size, +}: { + color: string; + size: number; +}) => ( + + + + +); + +export default function ManagerNavigator() { + const { t } = useTranslation(); + const { colors } = useTheme(); + const stackNavConfig = useMemo(() => getStackNavigatorConfig(colors), [ + colors, + ]); + return ( + + + + ); +} + +const Stack = createStackNavigator(); + +export function ManagerTabIcon(props: any) { + const isNavLocked = useIsNavLocked(); + const hasAvailableUpdate = useSelector(hasAvailableUpdateSelector); + + const content = ( + + ); + + if (isNavLocked) { + return {}}>{content}; + } + + return content; +} diff --git a/src/components/RootNavigator/MarketNavigator.js b/src/components/RootNavigator/MarketNavigator.js index 1c85ede098..48254eefbd 100644 --- a/src/components/RootNavigator/MarketNavigator.js +++ b/src/components/RootNavigator/MarketNavigator.js @@ -22,7 +22,11 @@ export default function MarketNavigator() { [colors], ); return ( - + diff --git a/src/components/RootNavigator/NotificationCenterNavigator.tsx b/src/components/RootNavigator/NotificationCenterNavigator.tsx new file mode 100644 index 0000000000..781d5a9a79 --- /dev/null +++ b/src/components/RootNavigator/NotificationCenterNavigator.tsx @@ -0,0 +1,101 @@ +import React, { useState } from "react"; +import { + createMaterialTopTabNavigator, + MaterialTopTabBarProps, +} from "@react-navigation/material-top-tabs"; +import { useTranslation } from "react-i18next"; +import { useAnnouncements } from "@ledgerhq/live-common/lib/notifications/AnnouncementProvider"; + +import { Flex } from "@ledgerhq/native-ui"; +import styled from "styled-components/native"; +import { TabsContainer } from "@ledgerhq/native-ui/components/Tabs/TemplateTabs"; +import { ChipTab } from "@ledgerhq/native-ui/components/Tabs/Chip"; +import NotificationCenterStatus from "../../screens/NotificationCenter/Status"; +import NotificationCenterNews from "../../screens/NotificationCenter/News"; +import { ScreenName } from "../../const"; + +const Tab = createMaterialTopTabNavigator(); + +const TabBarContainer = styled(Flex)` + border-bottom-width: 1px; + border-bottom-color: ${p => p.theme.colors.palette.neutral.c40}; + background-color: ${p => p.theme.colors.palette.background.main}; +`; + +function TabBar({ state, descriptors, navigation }: MaterialTopTabBarProps) { + return ( + + + {state.routes.map((route, index) => { + const { options } = descriptors[route.key]; + const label = options.title; + + const isActive = state.index === index; + + const onPress = () => { + const event = navigation.emit({ + type: "tabPress", + target: route.key, + canPreventDefault: true, + }); + + if (!isActive && !event.defaultPrevented) { + navigation.navigate(route.name); + } + }; + + return ( + + ); + })} + + + ); +} + +export default function NotificationCenterNavigator() { + const { t } = useTranslation(); + const { allIds, seenIds } = useAnnouncements(); + const [notificationsCount] = useState(allIds.length - seenIds.length); + + // Fixme Typescript: Update react-native-tab-view to 3.1.1 to remove Tab.navigator ts error + return ( + <> + {/* @ts-ignore */} + }> + 0 + ? "notificationCenter.news.titleCount" + : "notificationCenter.news.title", + { + count: notificationsCount, + }, + ), + }} + /> + + + + ); +} diff --git a/src/components/RootNavigator/OnboardingNavigator.tsx b/src/components/RootNavigator/OnboardingNavigator.tsx new file mode 100644 index 0000000000..edfd957340 --- /dev/null +++ b/src/components/RootNavigator/OnboardingNavigator.tsx @@ -0,0 +1,268 @@ +import React from "react"; +import { + createStackNavigator, + CardStyleInterpolators, + TransitionPresets, + StackNavigationOptions, + StackScreenProps, +} from "@react-navigation/stack"; +import { Flex } from "@ledgerhq/native-ui"; +import { useTranslation } from "react-i18next"; +import { ScreenName, NavigatorName } from "../../const"; +import PasswordAddFlowNavigator from "./PasswordAddFlowNavigator"; +import OnboardingWelcome from "../../screens/Onboarding/steps/welcome"; +import OnboardingLanguage from "../../screens/Onboarding/steps/language"; +import OnboardingTerms from "../../screens/Onboarding/steps/terms"; +import OnboardingDeviceSelection from "../../screens/Onboarding/steps/deviceSelection"; +import OnboardingUseCase from "../../screens/Onboarding/steps/useCaseSelection"; +import OnboardingNewDeviceInfo from "../../screens/Onboarding/steps/newDeviceInfo"; +import OnboardingNewDiscoverLiveInfo from "../../screens/Onboarding/steps/discoverLiveInfo"; +import OnboardingNewDevice from "../../screens/Onboarding/steps/setupDevice"; +import OnboardingRecoveryPhrase from "../../screens/Onboarding/steps/recoveryPhrase"; +import OnboardingInfoModal from "../OnboardingStepperView/OnboardingInfoModal"; + +import OnboardingPairNew from "../../screens/Onboarding/steps/pairNew"; +import OnboardingImportAccounts from "../../screens/Onboarding/steps/importAccounts"; +import OnboardingFinish from "../../screens/Onboarding/steps/finish"; +import OnboardingPreQuizModal from "../../screens/Onboarding/steps/setupDevice/drawers/OnboardingPreQuizModal"; +import OnboardingQuiz from "../../screens/Onboarding/OnboardingQuiz"; +import OnboardingQuizFinal from "../../screens/Onboarding/OnboardingQuizFinal"; +import NavigationHeader from "../NavigationHeader"; +import NavigationOverlay from "../NavigationOverlay"; +import NavigationModalContainer from "../NavigationModalContainer"; +import OnboardingSetupDeviceInformation from "../../screens/Onboarding/steps/setupDevice/drawers/SecurePinCode"; +import OnboardingSetupDeviceRecoveryPhrase from "../../screens/Onboarding/steps/setupDevice/drawers/SecureRecoveryPhrase"; +import OnboardingGeneralInformation from "../../screens/Onboarding/steps/setupDevice/drawers/GeneralInformation"; +import OnboardingBluetoothInformation from "../../screens/Onboarding/steps/setupDevice/drawers/BluetoothConnection"; +import OnboardingWarning from "../../screens/Onboarding/steps/setupDevice/drawers/Warning"; +import OnboardingSyncDesktopInformation from "../../screens/Onboarding/steps/setupDevice/drawers/SyncDesktopInformation"; +import OnboardingRecoveryPhraseWarning from "../../screens/Onboarding/steps/setupDevice/drawers/RecoveryPhraseWarning"; +import PostWelcomeSelection from "../../screens/Onboarding/steps/postWelcomeSelection"; + +const Stack = createStackNavigator(); +const OnboardingCarefulWarningStack = createStackNavigator(); +const OnboardingPreQuizModalStack = createStackNavigator(); + +function OnboardingCarefulWarning(props: StackScreenProps<{}>) { + const options: Partial = { + header: props => ( + // TODO: Replace this value with constant.purple as soon as the value is fixed in the theme + + + + ), + headerStyle: { backgroundColor: "transparent" }, + }; + + return ( + + + + + + + + ); +} +function OnboardingPreQuizModalNavigator(props: StackScreenProps<{}>) { + const options: Partial = { + header: props => ( + // TODO: Replace this value with constant.purple as soon as the value is fixed in the theme + + + + ), + headerStyle: {}, + headerShadowVisible: false, + }; + + return ( + + + + + + ); +} + +const modalOptions: Partial = { + presentation: "transparentModal", + cardOverlayEnabled: true, + cardOverlay: () => , + headerShown: false, + ...TransitionPresets.ModalTransition, +}; + +const infoModalOptions: Partial = { + ...TransitionPresets.ModalTransition, + headerShown: true, +}; + +export default function OnboardingNavigator() { + const { t } = useTranslation(); + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/RootNavigator/ReceiveFundsNavigator.js b/src/components/RootNavigator/ReceiveFundsNavigator.js index 79b409f22f..ff3772d173 100644 --- a/src/components/RootNavigator/ReceiveFundsNavigator.js +++ b/src/components/RootNavigator/ReceiveFundsNavigator.js @@ -25,7 +25,6 @@ export default function ReceiveFundsNavigator() { screenOptions={{ ...stackNavigationConfig, gestureEnabled: Platform.OS === "ios", - headerMode: "float", }} > getStackNavigatorConfig(colors, true), + [colors], + ); + return ( + + ( + + ), + }} + initialParams={{ + next: ScreenName.ReceiveConnectDevice, + category: "ReceiveFunds", + }} + /> + ({ + headerTitle: () => ( + + ), + })} + /> + ( + + ), + }} + /> + + ); +} + +const Stack = createStackNavigator(); diff --git a/src/components/RootNavigator/RequestAccountNavigator.js b/src/components/RootNavigator/RequestAccountNavigator.js index a211621bde..569baaf12c 100644 --- a/src/components/RootNavigator/RequestAccountNavigator.js +++ b/src/components/RootNavigator/RequestAccountNavigator.js @@ -23,7 +23,6 @@ export default function RequestAccountNavigator() { getStackNavigatorConfig(colors, true), + [colors], + ); + return ( + + ( + + ), + }} + initialParams={{ + next: ScreenName.SendSelectRecipient, + category: "SendFunds", + notEmptyAccounts: true, + minBalance: 0, + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + initialParams={{ + analyticsPropertyFlow: "send", + }} + /> + + + + ); +} + +const Stack = createStackNavigator(); diff --git a/src/components/RootNavigator/SettingsNavigator.js b/src/components/RootNavigator/SettingsNavigator.js index 7a79dfa5f2..211acb7ea5 100644 --- a/src/components/RootNavigator/SettingsNavigator.js +++ b/src/components/RootNavigator/SettingsNavigator.js @@ -49,7 +49,7 @@ export default function SettingsNavigator() { return ( getStackNavigatorConfig(colors), [ + colors, + ]); + return ( + + , + }} + /> + + + + + + + + + ({ + title: route.params.headerTitle, + headerRight: null, + })} + /> + + + + + + + + + + ({ + title: "Debug BLE", + headerRight: () => ( + + + ); +} + +const styles = StyleSheet.create({ + imageContainer: { + minHeight: 200, + position: "relative", + overflow: "visible", + }, + image: { + position: "absolute", + left: "5%", + top: 0, + width: "110%", + height: "100%", + }, +}); + +export default memo(BluetoothEmpty); diff --git a/src/components/SelectDevice/DeviceItem.tsx b/src/components/SelectDevice/DeviceItem.tsx new file mode 100644 index 0000000000..30b1f7b546 --- /dev/null +++ b/src/components/SelectDevice/DeviceItem.tsx @@ -0,0 +1,76 @@ +import React, { memo, useMemo, useCallback } from "react"; +import invariant from "invariant"; +import { TouchableOpacity } from "react-native"; +import { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import { + NanoFoldedMedium, + ToolsMedium, + OthersMedium, +} from "@ledgerhq/native-ui/assets/icons"; +import { SelectableList, Text } from "@ledgerhq/native-ui"; +import { IconType } from "@ledgerhq/native-ui/components/Icon/type"; + +type Props = { + deviceMeta: Device; + disabled?: boolean; + withArrow?: boolean; + description?: React.ReactNode; + onSelect?: (arg0: Device) => any; + onBluetoothDeviceAction?: (arg0: Device) => any; +}; + +const iconByFamily: Record = { + httpdebug: ToolsMedium, +}; + +function DeviceItem({ + deviceMeta, + onSelect, + disabled, + description, + onBluetoothDeviceAction, +}: Props) { + const onPress = useCallback(() => { + invariant(onSelect, "onSelect required"); + return onSelect(deviceMeta); + }, [deviceMeta, onSelect]); + + const family = deviceMeta.deviceId.split("|")[0]; + const CustomIcon = !!family && iconByFamily[family]; + + const onMore = useMemo( + () => + family !== "usb" && !!onBluetoothDeviceAction + ? () => onBluetoothDeviceAction(deviceMeta) + : undefined, + [family, onBluetoothDeviceAction, deviceMeta], + ); + + const renderOnMore = ( + + + + ); + + return ( + + {deviceMeta.deviceName} + {description && ( + + {" "} + ({description}) + + )} + + ); +} + +export default memo(DeviceItem); diff --git a/src/components/SelectDevice/USBEmpty.android.tsx b/src/components/SelectDevice/USBEmpty.android.tsx new file mode 100644 index 0000000000..e593455f35 --- /dev/null +++ b/src/components/SelectDevice/USBEmpty.android.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { Trans } from "react-i18next"; +import { Box, Flex, Text } from "@ledgerhq/native-ui"; +import { UsbMedium } from "@ledgerhq/native-ui/assets/icons"; + +export default function USBEmpty({ usbOnly }: { usbOnly: boolean }) { + return ( + + + + {!usbOnly && ( + + + + )} + + + + + + ); +} diff --git a/src/components/SelectDevice/index.tsx b/src/components/SelectDevice/index.tsx new file mode 100644 index 0000000000..54e470d403 --- /dev/null +++ b/src/components/SelectDevice/index.tsx @@ -0,0 +1,261 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { StyleSheet, View, Platform, NativeModules } from "react-native"; +import Config from "react-native-config"; +import { useSelector, useDispatch } from "react-redux"; +import { Trans } from "react-i18next"; +import { useNavigation } from "@react-navigation/native"; +import { discoverDevices, TransportModule } from "@ledgerhq/live-common/lib/hw"; +import { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import { Button } from "@ledgerhq/native-ui"; +import { useTheme } from "styled-components/native"; +import { ScreenName } from "../../const"; +import { knownDevicesSelector } from "../../reducers/ble"; +import { setHasConnectedDevice } from "../../actions/appstate"; +import DeviceItem from "./DeviceItem"; +import BluetoothEmpty from "./BluetoothEmpty"; +import USBEmpty from "./USBEmpty"; +import LText from "../LText"; +import Animation from "../Animation"; + +import lottieUsb from "../../screens/Onboarding/assets/nanoS/plugDevice/dark.json"; +import { track } from "../../analytics"; + +type Props = { + onBluetoothDeviceAction?: (device: Device) => void; + onSelect: (device: Device) => void; + onWithoutDevice?: () => void; + withArrows?: boolean; + usbOnly?: boolean; + filter?: (transportModule: TransportModule) => boolean; + autoSelectOnAdd?: boolean; + hideAnimation?: boolean; +}; + +export default function SelectDevice({ + usbOnly, + withArrows, + filter = () => true, + onSelect, + onWithoutDevice, + onBluetoothDeviceAction, + autoSelectOnAdd, + hideAnimation, +}: Props) { + const { colors } = useTheme(); + const navigation = useNavigation(); + const knownDevices = useSelector(knownDevicesSelector); + const dispatch = useDispatch(); + + const handleOnSelect = useCallback( + deviceInfo => { + NativeModules.BluetoothHelperModule.prompt() + .then(() => { + const { modelId, wired } = deviceInfo; + track("Device selection", { + modelId, + connectionType: wired ? "USB" : "BLE", + }); + // Nb consider a device selection enough to show the fw update banner in portfolio + dispatch(setHasConnectedDevice(true)); + onSelect(deviceInfo); + }) + .catch(() => { + /* ignore */ + }); + }, + [dispatch, onSelect], + ); + + const [devices, setDevices] = useState([]); + + const onPairNewDevice = useCallback(() => { + NativeModules.BluetoothHelperModule.prompt() + .then(() => + // @ts-expect-error navigation issue + navigation.navigate(ScreenName.PairDevices, { + onDone: autoSelectOnAdd ? handleOnSelect : null, + }), + ) + .catch(() => { + /* ignore */ + }); + }, [autoSelectOnAdd, navigation, handleOnSelect]); + + const renderItem = useCallback( + (item: Device) => ( + + ), + [withArrows, onBluetoothDeviceAction, handleOnSelect], + ); + + const all: Device[] = getAll({ knownDevices }, { devices }); + + const [ble, other] = all.reduce( + ([ble, other], device) => + device.wired ? [ble, [...other, device]] : [[...ble, device], other], + [[], []], + ); + + const hasUSBSection = Platform.OS === "android" || other.length > 0; + + useEffect(() => { + const subscription = discoverDevices(filter).subscribe(e => { + setDevices(devices => { + if (e.type !== "add") { + return devices.filter(d => d.deviceId !== e.id); + } + + if (!devices.find(d => d.deviceId === e.id)) { + return [ + ...devices, + { + deviceId: e.id, + deviceName: e.name || "", + modelId: + (e.deviceModel && e.deviceModel.id) || + Config?.FALLBACK_DEVICE_MODEL_ID || + "nanoX", + wired: e.id.startsWith("httpdebug|") + ? Config?.FALLBACK_DEVICE_WIRED === "YES" + : e.id.startsWith("usb|"), + }, + ]; + } + + return devices; + }); + }); + return () => subscription.unsubscribe(); + }, [knownDevices, filter]); + + return ( + <> + {usbOnly && withArrows && !hideAnimation ? ( + + ) : ble.length === 0 ? ( + + ) : ( + + + {ble.map(renderItem)} + + + )} + {hasUSBSection && + !usbOnly && + (ble.length === 0 ? ( + + ) : ( + + ))} + {other.length === 0 ? ( + + ) : ( + other.map(renderItem) + )} + {onWithoutDevice && ( + + + + + )} + + ); +} + +const BluetoothHeader = () => ( + + + + + +); + +const USBHeader = () => ( + + + +); + +const WithoutDeviceHeader = () => ( + + + + + +); + +// Fixme Use the illustration instead of the png +const UsbPlaceholder = () => ( + + + +); + +function getAll({ knownDevices }, { devices }): Device[] { + return [ + ...devices, + ...knownDevices.map(d => ({ + deviceId: d.id, + deviceName: d.name || "", + wired: false, + modelId: "nanoX", + })), + ]; +} + +const styles = StyleSheet.create({ + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + }, + headerText: { + fontSize: 14, + lineHeight: 21, + }, + separator: { + width: "100%", + height: 1, + marginVertical: 24, + }, + imageContainer: { + minHeight: 200, + position: "relative", + overflow: "visible", + }, + image: { + position: "absolute", + right: "-5%", + top: 0, + width: "110%", + height: "100%", + }, +}); diff --git a/src/components/SelectableAccountsList.tsx b/src/components/SelectableAccountsList.tsx new file mode 100644 index 0000000000..87c136ccf3 --- /dev/null +++ b/src/components/SelectableAccountsList.tsx @@ -0,0 +1,357 @@ +import React, { + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Trans } from "react-i18next"; +import { + Animated, + View, + TouchableOpacity, + PanResponder, + FlatList, +} from "react-native"; +import { useNavigation, useTheme } from "@react-navigation/native"; +import { listTokenTypesForCryptoCurrency } from "@ledgerhq/live-common/lib/currencies"; +import { Account } from "@ledgerhq/live-common/lib/types"; +import { FlexBoxProps } from "@ledgerhq/native-ui/components/Layout/Flex"; +import { Flex, Text } from "@ledgerhq/native-ui"; +import Swipeable from "react-native-gesture-handler/Swipeable"; + +import { ScreenName } from "../const"; +import { track } from "../analytics"; +import AccountCard from "./AccountCard"; +import CheckBox from "./CheckBox"; +import swipedAccountSubject from "../screens/AddAccounts/swipedAccountSubject"; +import Button from "./Button"; +import TouchHintCircle from "./TouchHintCircle"; +import Touchable from "./Touchable"; + +const selectAllHitSlop = { + top: 16, + left: 16, + right: 16, + bottom: 16, +}; + +type Props = FlexBoxProps & { + accounts: Account[]; + onPressAccount?: (_: Account) => void; + onSelectAll?: (_: Account[]) => void; + onUnselectAll?: (_: Account[]) => void; + selectedIds: string[]; + isDisabled?: boolean; + forceSelected?: boolean; + emptyState?: ReactNode; + header: ReactNode; + style?: any; + index: number; + showHint: boolean; + onAccountNameChange?: (name: string, changedAccount: Account) => void; + useFullBalance?: boolean; +}; + +const SelectableAccountsList = ({ + accounts, + onPressAccount, + onSelectAll: onSelectAllProp, + onUnselectAll: onUnselectAllProp, + selectedIds = [], + isDisabled = false, + forceSelected, + emptyState, + header, + showHint = false, + index: listIndex = -1, + onAccountNameChange, + useFullBalance, + ...props +}: Props) => { + const { colors } = useTheme(); + const navigation = useNavigation(); + + const onSelectAll = useCallback(() => { + track("SelectAllAccounts"); + onSelectAllProp && onSelectAllProp(accounts); + }, [accounts, onSelectAllProp]); + + const onUnselectAll = useCallback(() => { + track("UnselectAllAccounts"); + onUnselectAllProp && onUnselectAllProp(accounts); + }, [accounts, onUnselectAllProp]); + + const areAllSelected = accounts.every(a => selectedIds.indexOf(a.id) > -1); + + return ( + + {header ? ( +
+ ) : null} + item.id + index} + renderItem={({ item, index }) => ( + -1} + isDisabled={isDisabled} + onPress={onPressAccount} + colors={colors} + useFullBalance={useFullBalance} + /> + )} + ListEmptyComponent={() => emptyState || null} + /> + + ); +}; + +type SelectableAccountProps = { + account: Account; + onPress?: (_: Account) => void; + isDisabled?: boolean; + isSelected?: boolean; + showHint: boolean; + rowIndex: number; + listIndex: number; + navigation: any; + onAccountNameChange?: (name: string, changedAccount: Account) => void; + colors: any; + useFullBalance?: boolean; +}; + +const SelectableAccount = ({ + account, + onPress, + isDisabled, + isSelected, + showHint, + rowIndex, + listIndex, + navigation, + onAccountNameChange, + useFullBalance, +}: SelectableAccountProps) => { + const [stopAnimation, setStopAnimation] = useState(false); + + const swipeableRow = useRef(null); + + useEffect(() => { + const sub = swipedAccountSubject.subscribe(msg => { + const { row, list } = msg; + setStopAnimation(true); + if (swipeableRow.current && (row !== rowIndex || list !== listIndex)) { + swipeableRow.current.close(); + } + }); + + return () => { + sub.unsubscribe(); + }; + }, [listIndex, rowIndex, swipeableRow]); + + const panResponder = useMemo( + () => + PanResponder.create({ + // Ask to be the responder: + onStartShouldSetPanResponder: () => true, + onStartShouldSetPanResponderCapture: () => false, + onMoveShouldSetPanResponder: () => false, + onMoveShouldSetPanResponderCapture: () => false, + onPanResponderGrant: () => { + if (swipedAccountSubject) { + setStopAnimation(true); + swipedAccountSubject.next({ rowIndex, listIndex }); + } + }, + onShouldBlockNativeResponder: () => false, + }), + [rowIndex, listIndex], + ); + + const handlePress = () => { + track(isSelected ? "UnselectAccount" : "SelectAccount"); + if (onPress) { + onPress(account); + } + }; + + const renderLeftActions = ( + progress: Animated.AnimatedInterpolation, + dragX: Animated.AnimatedInterpolation, + ) => { + const translateX = dragX.interpolate({ + inputRange: [0, 1000], + outputRange: [-112, 888], + }); + + return ( + + +