diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 1bf88ef7..9fbc9fc0 100755 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,6 +1,6 @@ --- name: ✨ Feature Request -about: Any feature you find missing in Ledger Live? Discuss to suggest feature requests. For crypto asset support, please read Issue 1650. +about: Any feature you find missing in Ledger Live? Discuss to suggest feature requests. For crypto asset support, please read Issue #1560. --- - [ ] I have checked this feature was not yet requested. diff --git a/README.md b/README.md index d2632b44..cf5f8769 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Ledger Live (desktop) [![CircleCI](https://circleci.com/gh/LedgerHQ/ledger-live-desktop.svg?style=svg)](https://circleci.com/gh/LedgerHQ/ledger-live-desktop) [![Crowdin](https://d322cqt584bo4o.cloudfront.net/ledger-wallet/localized.svg)](https://crowdin.com/project/ledger-wallet) +- Related: [ledger-live-mobile](https://github.com/ledgerhq/ledger-live-mobile) + > Ledger Live is a new generation wallet desktop application providing a unique interface to maintain multiple cryptocurrencies for your Ledger Nano S / Blue. Manage your device, create accounts, receive and send cryptoassets, [...and many more](https://www.ledger.fr/2018/07/09/ledger-launches-ledger-live-the-all-in-one-companion-app-to-your-ledger-device/). @@ -85,6 +87,7 @@ SKIP_GENUINE=1 SKIP_ONBOARDING=1 SHOW_LEGACY_NEW_ACCOUNT=1 HIGHLIGHT_I18N=1 +EXPERIMENTAL_WS_EXPORT=0 ## constants GET_CALLS_TIMEOUT=30000 diff --git a/package.json b/package.json index e6d49112..fdc4a7d0 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test-e2e": "jest test-e2e", "test-sync": "bash test-e2e/sync/launch.sh", "prettier": "prettier --write \"{src,webpack,.storybook,test-e2e}/**/*.{js,json}\"", - "ci": "yarn lint && yarn flow && yarn prettier && yarn test", + "ci": "yarn check --integrity && ./scripts/check-no-dups.sh && yarn lint && yarn flow && yarn prettier && yarn test", "storybook": "NODE_ENV=development STORYBOOK_ENV=1 start-storybook -s ./static -p 4444", "publish-storybook": "bash ./scripts/legacy/publish-storybook.sh", "reset-files": "bash ./scripts/legacy/reset-files.sh" @@ -35,13 +35,14 @@ } }, "dependencies": { - "@ledgerhq/hw-app-btc": "^4.34.0", - "@ledgerhq/hw-app-eth": "^4.32.0", - "@ledgerhq/hw-app-xrp": "^4.32.0", - "@ledgerhq/hw-transport": "^4.32.0", - "@ledgerhq/hw-transport-node-hid": "^4.32.0", + "@ledgerhq/errors": "^4.35.1", + "@ledgerhq/hw-app-btc": "^4.35.0", + "@ledgerhq/hw-app-eth": "^4.35.0", + "@ledgerhq/hw-app-xrp": "^4.35.0", + "@ledgerhq/hw-transport": "^4.35.0", + "@ledgerhq/hw-transport-node-hid": "^4.35.0", "@ledgerhq/ledger-core": "2.0.0-rc.16", - "@ledgerhq/live-common": "4.14.1", + "@ledgerhq/live-common": "4.16.0", "animated": "^0.2.2", "async": "^2.6.1", "axios": "^0.18.0", @@ -64,18 +65,20 @@ "i18next": "^11.2.2", "i18next-node-fs-backend": "^1.0.0", "invariant": "^2.2.4", + "ip": "^1.1.5", "jsqr": "^1.1.1", "lodash": "^4.17.5", "lru-cache": "^4.1.3", "measure-scrollbar": "^1.1.0", "moment": "^2.22.2", + "openpgp": "^4.2.1", "qrcode": "^1.2.0", "qrloop": "0.8.1", "qs": "^6.5.1", "raven": "^2.5.0", "raven-js": "^3.24.2", - "react": "^16.6.1", - "react-dom": "^16.4.1", + "react": "^16.6.3", + "react-dom": "^16.6.3", "react-i18next": "^7.7.0", "react-key-handler": "^1.0.1", "react-markdown": "^3.3.2", @@ -111,7 +114,7 @@ "winston": "^3.0.0", "winston-transport": "^4.2.0", "write-file-atomic": "^2.3.0", - "ws": "^5.1.1", + "ws": "^6.1.3", "zxcvbn": "^4.4.2" }, "devDependencies": { @@ -182,7 +185,8 @@ "webpack": "^4.6.0", "webpack-bundle-analyzer": "^2.11.1", "webpack-cli": "^2.0.14", - "yaml-loader": "^0.5.0" + "yaml-loader": "^0.5.0", + "yarn-deduplicate": "^1.1.1" }, "engines": { "node": ">=8.9.0 <=8.15.0", diff --git a/scripts/check-no-dups.sh b/scripts/check-no-dups.sh new file mode 100755 index 00000000..3e93cf32 --- /dev/null +++ b/scripts/check-no-dups.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +yarn-deduplicate -l | grep \@ledgerhq + +if [ $? -eq 0 ]; then + echo "Found duplicates in @ledgerhq/* – fix it with yarn-deduplicate" + exit 1 +fi + +yarn-deduplicate -l | grep \"react + +if [ $? -eq 0 ]; then + echo "Found duplicates in some react packages – fix it with yarn-deduplicate" + exit 1 +fi diff --git a/scripts/create-release-shasums.sh b/scripts/create-release-shasums.sh new file mode 100644 index 00000000..d6d2166b --- /dev/null +++ b/scripts/create-release-shasums.sh @@ -0,0 +1,51 @@ +#!/bin/env bash + +# Fetch release binaries for all platforms +# and produce a .sha512sum file in the current folder + +# exit on error +set -e + +[[ "$GH_TOKEN" == "" ]] && echo "GH_TOKEN is unset" && exit 1 + +function main { + ASSETS_FILTER="(AppImage|zip|exe)" + PKG_VER=$(grep version package.json | sed -E 's/.*: "(.*)",/\1/g') + OUTPUT_FILE="ledger-live-desktop-$PKG_VER.sha512sum" + + read -p "> release version ($PKG_VER): " -r RELEASE_VERSION + RELEASE_VERSION=${RELEASE_VERSION:-$PKG_VER} + + RELEASES=$(do_request "/repos/LedgerHQ/ledger-live-desktop/releases") + printf """ + console.log( + (%s).find(r => r.tag_name === 'v%s').assets + .filter(a => a.name.match(/\\.%s$/)) + .map(a => a.browser_download_url) + .join('\\\n') + ) + """ "$RELEASES" "$RELEASE_VERSION" "$ASSETS_FILTER" >"$TMP_FILE1" + node "$TMP_FILE1" | tee "$TMP_FILE2" + + pushd "$TMP_DIR" >/dev/null + while IFS= read -r line ; do + curl -L -O "$line" + done < "$TMP_FILE2" + sha512sum -- * > "$OLDPWD/$OUTPUT_FILE" + popd >/dev/null +} + +TMP_DIR=$(mktemp -d) +TMP_FILE1=$(mktemp) +TMP_FILE2=$(mktemp) + +function cleanup { + rm -rf "$TMP_FILE1" "$TMP_FILE2" "$TMP_DIR" +} + +function do_request { + curl -H "Authorization: token $GH_TOKEN" "https://api.github.com$1" +} + +trap cleanup EXIT +main diff --git a/scripts/release.sh b/scripts/release.sh index 34a31fcb..e95ec8d3 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -67,6 +67,7 @@ if [[ $(uname) == 'Linux' ]]; then # only run it on one target, to prevent race "failed to create a draft release" fi +runJob "rm -rf ./node_modules/.cache" "Removing node modules cache..." "done" "fail" runJob "yarn compile" "compiling..." "compiled" "failed to compile" "verbose" if [[ $(uname) == 'Linux' ]]; then diff --git a/src/api/Ethereum.js b/src/api/Ethereum.js deleted file mode 100644 index bd7b2160..00000000 --- a/src/api/Ethereum.js +++ /dev/null @@ -1,91 +0,0 @@ -// @flow -import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types' -import { BigNumber } from 'bignumber.js' -import { LedgerAPINotAvailable } from '@ledgerhq/errors' -import network from './network' -import { blockchainBaseURL } from './Ledger' - -export type Block = { height: number } // TODO more fields actually -export type Tx = { - hash: string, - received_at: string, - nonce: string, - value: number, - gas: number, - gas_price: number, - cumulative_gas_used: number, - gas_used: number, - from: string, - to: string, - input: string, - index: number, - block?: { - hash: string, - height: number, - time: string, - }, - confirmations: number, -} - -export type API = { - getTransactions: ( - address: string, - blockHash: ?string, - ) => Promise<{ - truncated: boolean, - txs: Tx[], - }>, - getCurrentBlock: () => Promise, - getAccountNonce: (address: string) => Promise, - broadcastTransaction: (signedTransaction: string) => Promise, - getAccountBalance: (address: string) => Promise, -} - -export const apiForCurrency = (currency: CryptoCurrency): API => { - const baseURL = blockchainBaseURL(currency) - if (!baseURL) { - throw new LedgerAPINotAvailable(`LedgerAPINotAvailable ${currency.id}`, { - currencyName: currency.name, - }) - } - return { - async getTransactions(address, blockHash) { - const { data } = await network({ - method: 'GET', - url: `${baseURL}/addresses/${address}/transactions`, - params: { blockHash, noToken: 1 }, - }) - return data - }, - async getCurrentBlock() { - const { data } = await network({ - method: 'GET', - url: `${baseURL}/blocks/current`, - }) - return data - }, - async getAccountNonce(address) { - const { data } = await network({ - method: 'GET', - url: `${baseURL}/addresses/${address}/nonce`, - }) - return data[0].nonce - }, - async broadcastTransaction(tx) { - const { data } = await network({ - method: 'POST', - url: `${baseURL}/transactions/send`, - data: { tx }, - }) - return data.result - }, - async getAccountBalance(address) { - const { data } = await network({ - method: 'GET', - url: `${baseURL}/addresses/${address}/balance`, - }) - // FIXME precision lost here. nothing we can do easily - return BigNumber(data[0].balance) - }, - } -} diff --git a/src/api/Fees.js b/src/api/Fees.js deleted file mode 100644 index 84623f88..00000000 --- a/src/api/Fees.js +++ /dev/null @@ -1,31 +0,0 @@ -// @flow -import invariant from 'invariant' -import LRU from 'lru-cache' -import type { Currency } from '@ledgerhq/live-common/lib/types' -import { FeeEstimationFailed } from '@ledgerhq/errors' -import { blockchainBaseURL } from './Ledger' -import network from './network' - -export type Fees = { - [_: string]: number, -} - -const cache = LRU({ - maxAge: 5 * 60 * 1000, -}) - -export const getEstimatedFees = async (currency: Currency): Promise => { - const key = currency.id - let promise = cache.get(key) - if (promise) return promise.then(r => r.data) - const baseURL = blockchainBaseURL(currency) - invariant(baseURL, `Fees for ${currency.id} are not supported`) - promise = network({ method: 'GET', url: `${baseURL}/fees` }) - cache.set(key, promise) - const { data, status } = await promise - if (status < 200 || status >= 300) cache.del(key) - if (data) { - return data - } - throw new FeeEstimationFailed(`FeeEstimationFailed ${status}`, { httpStatus: status }) -} diff --git a/src/api/Ledger.js b/src/api/Ledger.js deleted file mode 100644 index f5c5e9a6..00000000 --- a/src/api/Ledger.js +++ /dev/null @@ -1,6 +0,0 @@ -// @flow -import type { Currency } from '@ledgerhq/live-common/lib/types' -import { LEDGER_REST_API_BASE } from 'config/constants' - -export const blockchainBaseURL = ({ ledgerExplorerId }: Currency): ?string => - ledgerExplorerId ? `${LEDGER_REST_API_BASE}/blockchain/v2/${ledgerExplorerId}` : null diff --git a/src/api/Ripple.js b/src/api/Ripple.js deleted file mode 100644 index 2c4be71a..00000000 --- a/src/api/Ripple.js +++ /dev/null @@ -1,47 +0,0 @@ -// @flow -import logger from 'logger' -import { BigNumber } from 'bignumber.js' -import { RippleAPI } from 'ripple-lib' -import { - parseCurrencyUnit, - getCryptoCurrencyById, - formatCurrencyUnit, -} from '@ledgerhq/live-common/lib/currencies' - -const rippleUnit = getCryptoCurrencyById('ripple').units[0] - -export const defaultEndpoint = 'wss://s2.ripple.com' - -export const apiForEndpointConfig = (endpointConfig: ?string = null) => { - const server = endpointConfig || defaultEndpoint - const api = new RippleAPI({ server }) - api.on('error', (errorCode, errorMessage) => { - logger.warn(`Ripple API error: ${errorCode}: ${errorMessage}`) - }) - return api -} - -export const parseAPIValue = (value: string) => parseCurrencyUnit(rippleUnit, value) - -export const parseAPICurrencyObject = ({ - currency, - value, -}: { - currency: string, - value: string, -}) => { - if (currency !== 'XRP') { - logger.warn(`RippleJS: attempt to parse unknown currency ${currency}`) - return BigNumber(0) - } - return parseAPIValue(value) -} - -export const formatAPICurrencyXRP = (amount: BigNumber) => { - const value = formatCurrencyUnit(rippleUnit, amount, { - showAllDigits: true, - disableRounding: true, - useGrouping: false, - }) - return { currency: 'XRP', value } -} diff --git a/src/bridge/BridgeSyncContext.js b/src/bridge/BridgeSyncContext.js index b3211ffb..0e93cf44 100644 --- a/src/bridge/BridgeSyncContext.js +++ b/src/bridge/BridgeSyncContext.js @@ -100,6 +100,7 @@ class Provider extends Component { next() }, error: error => { + logger.critical(error) this.props.setAccountSyncState(accountId, { pending: false, error }) next() }, diff --git a/src/bridge/EthereumJSBridge.js b/src/bridge/EthereumJSBridge.js index 3282d1b4..f7948a46 100644 --- a/src/bridge/EthereumJSBridge.js +++ b/src/bridge/EthereumJSBridge.js @@ -22,8 +22,8 @@ import { } from '@ledgerhq/live-common/lib/account' import type { Account, Operation } from '@ledgerhq/live-common/lib/types' import eip55 from 'eip55' -import { apiForCurrency } from 'api/Ethereum' -import type { Tx } from 'api/Ethereum' +import { apiForCurrency } from '@ledgerhq/live-common/lib/api/Ethereum' +import type { Tx } from '@ledgerhq/live-common/lib/api/Ethereum' import getAddressCommand from 'commands/getAddress' import signTransactionCommand from 'commands/signTransaction' import { NotEnoughBalance, FeeNotLoaded, ETHAddressNonEIP } from '@ledgerhq/errors' @@ -129,7 +129,7 @@ function isRecipientValid(currency, recipient) { } // Returns a warning if we detect a non-eip address -function getRecipientWarning(currency, recipient) { +function getRecipientWarning(account, recipient) { if (!recipient.match(/^0x[0-9a-fA-F]{40}$/)) return null const slice = recipient.substr(2) const isFullUpper = slice === slice.toUpperCase() @@ -420,9 +420,9 @@ const EthereumBridge: WalletBridge = { pullMoreOperations: () => Promise.resolve(a => a), // NOT IMPLEMENTED - isRecipientValid: (currency, recipient) => Promise.resolve(isRecipientValid(currency, recipient)), - getRecipientWarning: (currency, recipient) => - Promise.resolve(getRecipientWarning(currency, recipient)), + isRecipientValid: (account, recipient) => Promise.resolve(isRecipientValid(account, recipient)), + getRecipientWarning: (account, recipient) => + Promise.resolve(getRecipientWarning(account, recipient)), createTransaction: () => ({ amount: BigNumber(0), diff --git a/src/bridge/LibcoreBridge.js b/src/bridge/LibcoreBridge.js index e9d2ce8b..e6fb6d40 100644 --- a/src/bridge/LibcoreBridge.js +++ b/src/bridge/LibcoreBridge.js @@ -60,15 +60,15 @@ const EditAdvancedOptions = ({ onChange, value }: EditProps) => ( const recipientValidLRU = LRU({ max: 100 }) -const isRecipientValid = (currency, recipient) => { - const key = `${currency.id}_${recipient}` +const isRecipientValid = (account, recipient) => { + const key = `${account.currency.id}_${recipient}` let promise = recipientValidLRU.get(key) if (promise) return promise if (!recipient) return Promise.resolve(false) promise = libcoreValidAddress .send({ address: recipient, - currencyId: currency.id, + currencyId: account.currency.id, }) .toPromise() recipientValidLRU.set(key, promise) @@ -83,7 +83,7 @@ const getFeesKey = (a, t) => }` const getFees = async (a, transaction) => { - const isValid = await isRecipientValid(a.currency, transaction.recipient) + const isValid = await isRecipientValid(a, transaction.recipient) if (!isValid) return null const key = getFeesKey(a, transaction) let promise = feesLRU.get(key) diff --git a/src/bridge/RippleJSBridge.js b/src/bridge/RippleJSBridge.js index eff799e6..a6d16ef6 100644 --- a/src/bridge/RippleJSBridge.js +++ b/src/bridge/RippleJSBridge.js @@ -3,6 +3,7 @@ import invariant from 'invariant' import { BigNumber } from 'bignumber.js' import { Observable } from 'rxjs' import React from 'react' +import { RippleAPI } from 'ripple-lib' import bs58check from 'ripple-bs58check' import { computeBinaryTransactionHash } from 'ripple-hashes' import throttle from 'lodash/throttle' @@ -26,13 +27,14 @@ import { parseAPIValue, parseAPICurrencyObject, formatAPICurrencyXRP, -} from 'api/Ripple' +} from '@ledgerhq/live-common/lib/api/Ripple' import FeesRippleKind from 'components/FeesField/RippleKind' import AdvancedOptionsRippleKind from 'components/AdvancedOptions/RippleKind' import { NotEnoughBalance, FeeNotLoaded, NotEnoughBalanceBecauseDestinationNotCreated, + InvalidAddressBecauseDestinationIsAlsoSource, } from '@ledgerhq/errors' import type { WalletBridge, EditProps } from './types' @@ -63,7 +65,7 @@ const EditAdvancedOptions = ({ onChange, value }: EditProps) => ( ) async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOperationBroadcasted }) { - const api = apiForEndpointConfig(a.endpointConfig) + const api = apiForEndpointConfig(RippleAPI, a.endpointConfig) const { fee } = t if (!fee) throw new FeeNotLoaded() try { @@ -135,15 +137,23 @@ async function signAndBroadcast({ a, t, deviceId, isCancelled, onSigned, onOpera } } -function isRecipientValid(recipient) { +function isRecipientValid(account, recipient) { try { bs58check.decode(recipient) - return true + + return !(account && account.freshAddress === recipient) } catch (e) { return false } } +function getRecipientWarning(account, recipient) { + if (account.freshAddress === recipient) { + return new InvalidAddressBecauseDestinationIsAlsoSource() + } + return null +} + function mergeOps(existing: Operation[], newFetched: Operation[]) { const ids = existing.map(o => o.id) const all = existing.concat(newFetched.filter(o => !ids.includes(o.id))) @@ -252,7 +262,7 @@ const getServerInfo = (map => endpointConfig => { if (!endpointConfig) endpointConfig = '' if (map[endpointConfig]) return map[endpointConfig]() const f = throttle(async () => { - const api = apiForEndpointConfig(endpointConfig) + const api = apiForEndpointConfig(RippleAPI, endpointConfig) try { await api.connect() const res = await api.getServerInfo() @@ -269,8 +279,8 @@ const getServerInfo = (map => endpointConfig => { })({}) const recipientIsNew = async (endpointConfig, recipient) => { - if (!isRecipientValid(recipient)) return false - const api = apiForEndpointConfig(endpointConfig) + if (!isRecipientValid(null, recipient)) return false + const api = apiForEndpointConfig(RippleAPI, endpointConfig) try { await api.connect() try { @@ -302,7 +312,7 @@ const RippleJSBridge: WalletBridge = { } async function main() { - const api = apiForEndpointConfig() + const api = apiForEndpointConfig(RippleAPI) try { await api.connect() const serverInfo = await getServerInfo() @@ -423,7 +433,7 @@ const RippleJSBridge: WalletBridge = { } async function main() { - const api = apiForEndpointConfig(endpointConfig) + const api = apiForEndpointConfig(RippleAPI, endpointConfig) try { await api.connect() if (finished) return @@ -504,8 +514,9 @@ const RippleJSBridge: WalletBridge = { pullMoreOperations: () => Promise.resolve(a => a), // FIXME not implemented - isRecipientValid: (currency, recipient) => Promise.resolve(isRecipientValid(recipient)), - getRecipientWarning: () => Promise.resolve(null), + isRecipientValid: (account, recipient) => Promise.resolve(isRecipientValid(account, recipient)), + getRecipientWarning: (account, recipient) => + Promise.resolve(getRecipientWarning(account, recipient)), createTransaction: () => ({ amount: BigNumber(0), @@ -617,7 +628,7 @@ const RippleJSBridge: WalletBridge = { getDefaultEndpointConfig: () => defaultEndpoint, validateEndpointConfig: async endpointConfig => { - const api = apiForEndpointConfig(endpointConfig) + const api = apiForEndpointConfig(RippleAPI, endpointConfig) await api.connect() }, } diff --git a/src/bridge/index.js b/src/bridge/index.js index 2fdabe04..d97b8753 100644 --- a/src/bridge/index.js +++ b/src/bridge/index.js @@ -1,5 +1,5 @@ // @flow -import type { Currency } from '@ledgerhq/live-common/lib/types' +import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types' import invariant from 'invariant' import { USE_MOCK_DATA } from 'config/constants' import { WalletBridge } from './types' @@ -20,7 +20,7 @@ if (USE_MOCK_DATA) { perFamily.ethereum = mockBridge perFamily.ripple = mockBridge } -export const getBridgeForCurrency = (currency: Currency): WalletBridge => { +export const getBridgeForCurrency = (currency: CryptoCurrency): WalletBridge => { const bridge = perFamily[currency.family] invariant(bridge, `${currency.id} currency is not supported`) return bridge diff --git a/src/bridge/makeMockBridge.js b/src/bridge/makeMockBridge.js index 93080000..169bbd4d 100644 --- a/src/bridge/makeMockBridge.js +++ b/src/bridge/makeMockBridge.js @@ -128,7 +128,7 @@ function makeMockBridge(opts?: Opts): WalletBridge<*> { } }, - isRecipientValid: (currency, recipient) => Promise.resolve(recipient.length > 0), + isRecipientValid: (account, recipient) => Promise.resolve(recipient.length > 0), getRecipientWarning: () => Promise.resolve(null), createTransaction: () => ({ diff --git a/src/bridge/types.js b/src/bridge/types.js index eea8cefa..f5d95b0c 100644 --- a/src/bridge/types.js +++ b/src/bridge/types.js @@ -2,7 +2,7 @@ import type { Observable } from 'rxjs' import type { BigNumber } from 'bignumber.js' -import type { Account, Operation, Currency } from '@ledgerhq/live-common/lib/types' +import type { Account, Operation, CryptoCurrency } from '@ledgerhq/live-common/lib/types' // a WalletBridge is implemented on renderer side. // this is an abstraction on top of libcore / ethereumjs / ripple js / ... @@ -34,7 +34,7 @@ export interface WalletBridge { // the scan can stop once all accounts are discovered. // the function returns a Subscription and you MUST stop everything if it is unsubscribed. // TODO return Observable - scanAccountsOnDevice(currency: Currency, deviceId: DeviceId): Observable; + scanAccountsOnDevice(currency: CryptoCurrency, deviceId: DeviceId): Observable; // synchronize an account. meaning updating the account object with latest state. // function receives the initialAccount object so you can actually know what the user side currently have @@ -52,8 +52,8 @@ export interface WalletBridge { // count is user's desired number of ops to pull (but implementation can decide to ignore it or not) pullMoreOperations(initialAccount: Account, count: number): Promise<(Account) => Account>; - isRecipientValid(currency: Currency, recipient: string): Promise; - getRecipientWarning(currency: Currency, recipient: string): Promise; + isRecipientValid(account: Account, recipient: string): Promise; + getRecipientWarning(account: Account, recipient: string): Promise; // Related to send funds: diff --git a/src/commands/.DS_Store b/src/commands/.DS_Store deleted file mode 100644 index 5008ddfc..00000000 Binary files a/src/commands/.DS_Store and /dev/null differ diff --git a/src/commands/autoUpdate.js b/src/commands/autoUpdate.js new file mode 100644 index 00000000..d0a95010 --- /dev/null +++ b/src/commands/autoUpdate.js @@ -0,0 +1,59 @@ +// @flow + +import { createCommand, Command } from 'helpers/ipc' +import { Observable } from 'rxjs' + +// import { UPDATE_CHECK_IGNORE, UPDATE_CHECK_FEED } from 'config/constants' +import { UPDATE_CHECK_IGNORE } from 'config/constants' +// import createElectronAppUpdater from 'main/updater/createElectronAppUpdater' +import type { UpdateStatus } from 'components/Updater/UpdaterContext' + +type Input = {} +type Result = { + status: UpdateStatus, + payload?: *, +} + +const cmd: Command = createCommand('main:autoUpdate', () => + Observable.create(o => { + const { autoUpdater } = require('electron-updater') + + const sendStatus = (status, payload) => { + o.next({ status, payload }) + } + + const handleDownload = async _ => { + try { + sendStatus('checking') + // const appUpdater = await createElectronAppUpdater({ + // feedURL: UPDATE_CHECK_FEED, + // updateVersion: info.version, + // }) + // await appUpdater.verify() + sendStatus('check-success') + } catch (err) { + // don't throw if the check fail for now. it's a white bullet. + if (UPDATE_CHECK_IGNORE) { + // TODO: track the error + sendStatus('check-success') + } else { + o.error(err) + } + } + } + + autoUpdater.on('checking-for-update', () => sendStatus('checking-for-update')) + autoUpdater.on('update-available', info => sendStatus('update-available', info)) + autoUpdater.on('update-not-available', info => sendStatus('update-not-available', info)) + autoUpdater.on('download-progress', p => sendStatus('download-progress', p)) + autoUpdater.on('update-downloaded', handleDownload) + autoUpdater.on('error', err => o.error(err)) + + autoUpdater.autoInstallOnAppQuit = false + autoUpdater.checkForUpdates() + + return () => {} + }), +) + +export default cmd diff --git a/src/commands/index.js b/src/commands/index.js index c9f32998..893c6d53 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -4,6 +4,7 @@ import invariant from 'invariant' import type { Command } from 'helpers/ipc' import debugAppInfosForCurrency from 'commands/debugAppInfosForCurrency' +import autoUpdate from 'commands/autoUpdate' import firmwarePrepare from 'commands/firmwarePrepare' import firmwareMain from 'commands/firmwareMain' import firmwareRepair from 'commands/firmwareRepair' @@ -22,6 +23,7 @@ import libcoreSyncAccount from 'commands/libcoreSyncAccount' import libcoreValidAddress from 'commands/libcoreValidAddress' import listenDevices from 'commands/listenDevices' import ping from 'commands/ping' +import quitAndInstallElectronUpdate from 'commands/quitAndInstallElectronUpdate' import signTransaction from 'commands/signTransaction' import testApdu from 'commands/testApdu' import testCrash from 'commands/testCrash' @@ -29,6 +31,7 @@ import testInterval from 'commands/testInterval' import uninstallApp from 'commands/uninstallApp' const all: Array> = [ + autoUpdate, debugAppInfosForCurrency, firmwarePrepare, firmwareMain, @@ -48,6 +51,7 @@ const all: Array> = [ libcoreValidAddress, listenDevices, ping, + quitAndInstallElectronUpdate, signTransaction, testApdu, testCrash, diff --git a/src/commands/libcoreSignAndBroadcast.js b/src/commands/libcoreSignAndBroadcast.js index a5847864..d44c94c3 100644 --- a/src/commands/libcoreSignAndBroadcast.js +++ b/src/commands/libcoreSignAndBroadcast.js @@ -129,7 +129,7 @@ async function signTransaction({ const hexPreviousTransaction = Buffer.from(rawPreviousTransaction).toString('hex') const previousTransaction = hwApp.splitTransaction( hexPreviousTransaction, - true, // set to true allow both segwit AND non-segwit + currency.supportsSegwit, hasTimestamp, hasExtraData, additionals, diff --git a/src/commands/quitAndInstallElectronUpdate.js b/src/commands/quitAndInstallElectronUpdate.js new file mode 100644 index 00000000..4ed02af8 --- /dev/null +++ b/src/commands/quitAndInstallElectronUpdate.js @@ -0,0 +1,30 @@ +// @flow + +import { createCommand, Command } from 'helpers/ipc' +import { Observable } from 'rxjs' + +type Input = void +type Result = void + +const cmd: Command = createCommand('main:quitAndInstallElectronUpdate', () => + Observable.create(o => { + const { app, BrowserWindow } = require('electron') + const { autoUpdater } = require('electron-updater') + const browserWindows = BrowserWindow.getAllWindows() + + // Fixes quitAndInstall not quitting on macOS, as suggested on + // https://github.com/electron-userland/electron-builder/issues/1604#issuecomment-306709572 + app.removeAllListeners('window-all-closed') + browserWindows.forEach(browserWindow => { + browserWindow.removeAllListeners('close') + }) + + // couldn't find a way to catch if fail ¯\_(ツ)_/¯ + autoUpdater.quitAndInstall(false) + + o.complete() + return () => {} + }), +) + +export default cmd diff --git a/src/components/App.js b/src/components/App.js index 76a876e7..b813110a 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -16,6 +16,7 @@ import ThrowBlock from 'components/ThrowBlock' import Default from 'components/layout/Default' import CounterValues from 'helpers/countervalues' import { BridgeSyncProvider } from 'bridge/BridgeSyncContext' +import { UpdaterProvider } from 'components/Updater/UpdaterContext' const App = ({ store, @@ -31,13 +32,15 @@ const App = ({ - - - - - - - + + + + + + + + + diff --git a/src/components/CounterValue/index.js b/src/components/CounterValue/index.js index 0caea538..fb23a1fd 100644 --- a/src/components/CounterValue/index.js +++ b/src/components/CounterValue/index.js @@ -3,7 +3,7 @@ import type { BigNumber } from 'bignumber.js' import React, { PureComponent } from 'react' import { connect } from 'react-redux' -import type { Currency } from '@ledgerhq/live-common/lib/types' +import type { CryptoCurrency, Currency } from '@ledgerhq/live-common/lib/types' import { counterValueCurrencySelector, @@ -19,7 +19,7 @@ import type { State } from 'reducers' type OwnProps = { // wich market to query - currency: Currency, + currency: CryptoCurrency, // when? if not given: take latest date?: Date, diff --git a/src/components/CurrenciesStatusBanner.js b/src/components/CurrenciesStatusBanner.js index 1b2123e7..2138f73d 100644 --- a/src/components/CurrenciesStatusBanner.js +++ b/src/components/CurrenciesStatusBanner.js @@ -6,7 +6,7 @@ import { translate } from 'react-i18next' import { connect } from 'react-redux' import { createStructuredSelector } from 'reselect' import styled from 'styled-components' -import type { Currency } from '@ledgerhq/live-common/lib/types' +import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types' import { colors } from 'styles/theme' import { openURL } from 'helpers/linking' @@ -59,7 +59,7 @@ const CloseIcon = (props: *) => ( ) type Props = { - accountsCurrencies: Currency[], + accountsCurrencies: CryptoCurrency[], dismissedBanners: string[], dismissBanner: string => void, currenciesStatus: CurrencyStatus[], diff --git a/src/components/CurrentAddress/index.js b/src/components/CurrentAddress/index.js index 9b7c6dd6..fc8d4de3 100644 --- a/src/components/CurrentAddress/index.js +++ b/src/components/CurrentAddress/index.js @@ -145,8 +145,11 @@ class CurrentAddress extends PureComponent { componentWillUnmount() { if (this._timeout) clearTimeout(this._timeout) + this._isUnmounted = true } + _isUnmounted = false + renderCopy = copy => { const { t } = this.props return ( diff --git a/src/components/DashboardPage/index.js b/src/components/DashboardPage/index.js index 203651b7..f3317bb5 100644 --- a/src/components/DashboardPage/index.js +++ b/src/components/DashboardPage/index.js @@ -3,6 +3,7 @@ import React, { PureComponent, Fragment } from 'react' import uniq from 'lodash/uniq' import { compose } from 'redux' +import IconNanoX from 'icons/device/NanoX' import { translate } from 'react-i18next' import { connect } from 'react-redux' import { push } from 'react-router-redux' @@ -24,17 +25,21 @@ import { saveSettings } from 'actions/settings' import TrackPage from 'analytics/TrackPage' import RefreshAccountsOrdering from 'components/RefreshAccountsOrdering' -import UpdateNotifier from 'components/UpdateNotifier' +import UpdateBanner from 'components/Updater/Banner' import BalanceInfos from 'components/BalanceSummary/BalanceInfos' import BalanceSummary from 'components/BalanceSummary' import Box from 'components/base/Box' import PillsDaysCount from 'components/PillsDaysCount' import OperationsList from 'components/OperationsList' import StickyBackToTop from 'components/StickyBackToTop' +import styled from 'styled-components' +import { openURL } from 'helpers/linking' import EmptyState from './EmptyState' import CurrentGreetings from './CurrentGreetings' import SummaryDesc from './SummaryDesc' import AccountCardList from './AccountCardList' +import TopBanner, { FakeLink } from '../TopBanner' +import { urls } from '../../config/urls' const mapStateToProps = createStructuredSelector({ accounts: accountsSelector, @@ -84,7 +89,24 @@ class DashboardPage extends PureComponent { return ( - + + + openURL(urls.promoNanoX)}> + {t('common.learnMore')} + + ), + }} + status={'dark'} + bannerId={'promoNanoX'} + dismissable + /> + + { ) } } +// This forces only one visible top banner at a time +const TopBannerContainer = styled.div` + & > *:not(:first-child) { + display: none; + } +` +// If no banners are present, the SeparatorBar appears +const SeparatorBar = styled.div` + height: 1px; + border-bottom: 1px solid ${p => p.theme.colors.fog}; + margin-bottom: 15px; + margin-top: -20px; +` export default compose( connect( diff --git a/src/components/DevToolsPage/AccountImporter.js b/src/components/DevToolsPage/AccountImporter.js index 17a525c1..66fcba72 100644 --- a/src/components/DevToolsPage/AccountImporter.js +++ b/src/components/DevToolsPage/AccountImporter.js @@ -6,7 +6,7 @@ import React, { PureComponent, Fragment } from 'react' import invariant from 'invariant' import { connect } from 'react-redux' -import type { Currency, Account, DerivationMode } from '@ledgerhq/live-common/lib/types' +import type { CryptoCurrency, Account, DerivationMode } from '@ledgerhq/live-common/lib/types' import { decodeAccount } from 'reducers/accounts' import { addAccount } from 'actions/accounts' @@ -37,7 +37,7 @@ type Props = { type ImportableAccountType = { name: string, - currency: Currency, + currency: CryptoCurrency, derivationMode: DerivationMode, xpub: string, } @@ -47,7 +47,7 @@ type State = { importableAccounts: ImportableAccountType[], - currency: ?Currency, + currency: ?CryptoCurrency, xpub: string, name: string, isSegwit: boolean, @@ -72,7 +72,7 @@ const INITIAL_STATE = { class AccountImporter extends PureComponent { state = INITIAL_STATE - onChangeCurrency = currency => { + onChangeCurrency = (currency: CryptoCurrency) => { if (currency.family !== 'bitcoin') return this.setState({ currency, @@ -119,6 +119,7 @@ class AccountImporter extends PureComponent { addToScan = () => { const { xpub, currency, isSegwit, isUnsplit, name } = this.state + if (!currency) return const derivationMode = isSegwit ? isUnsplit ? 'segwit_unsplit' diff --git a/src/components/FeesField/BitcoinKind.js b/src/components/FeesField/BitcoinKind.js index 937e0702..3ff83fe9 100644 --- a/src/components/FeesField/BitcoinKind.js +++ b/src/components/FeesField/BitcoinKind.js @@ -11,7 +11,7 @@ import type { T } from 'types/common' import { FeeNotLoaded } from '@ledgerhq/errors' import InputCurrency from 'components/base/InputCurrency' import Select from 'components/base/Select' -import type { Fees } from 'api/Fees' +import type { Fees } from '@ledgerhq/live-common/lib/api/Fees' import WithFeesAPI from '../WithFeesAPI' import GenericContainer from './GenericContainer' import Box from '../base/Box' diff --git a/src/components/FeesField/EthereumKind.js b/src/components/FeesField/EthereumKind.js index d6ef3d6d..e07fda28 100644 --- a/src/components/FeesField/EthereumKind.js +++ b/src/components/FeesField/EthereumKind.js @@ -6,7 +6,7 @@ import type { Account } from '@ledgerhq/live-common/lib/types' import { FeeNotLoaded } from '@ledgerhq/errors' import InputCurrency from 'components/base/InputCurrency' -import type { Fees } from 'api/Fees' +import type { Fees } from '@ledgerhq/live-common/lib/api/Fees' import WithFeesAPI from '../WithFeesAPI' import GenericContainer from './GenericContainer' diff --git a/src/components/FeesField/RippleKind.js b/src/components/FeesField/RippleKind.js index acf7622e..93907978 100644 --- a/src/components/FeesField/RippleKind.js +++ b/src/components/FeesField/RippleKind.js @@ -1,9 +1,10 @@ // @flow import React, { Component } from 'react' +import { RippleAPI } from 'ripple-lib' import type { BigNumber } from 'bignumber.js' import type { Account } from '@ledgerhq/live-common/lib/types' -import { apiForEndpointConfig, parseAPIValue } from 'api/Ripple' +import { apiForEndpointConfig, parseAPIValue } from '@ledgerhq/live-common/lib/api/Ripple' import { FeeNotLoaded } from '@ledgerhq/errors' import InputCurrency from 'components/base/InputCurrency' import GenericContainer from './GenericContainer' @@ -30,7 +31,7 @@ class FeesField extends Component { } syncId = 0 async sync() { - const api = apiForEndpointConfig(this.props.account.endpointConfig) + const api = apiForEndpointConfig(RippleAPI, this.props.account.endpointConfig) const syncId = ++this.syncId try { await api.connect() diff --git a/src/components/GenuineCheckModal.js b/src/components/GenuineCheckModal.js index e70d17ec..d9f4f7a1 100644 --- a/src/components/GenuineCheckModal.js +++ b/src/components/GenuineCheckModal.js @@ -5,7 +5,7 @@ import { translate } from 'react-i18next' import type { T } from 'types/common' -import Modal, { ModalBody, ModalTitle, ModalContent } from 'components/base/Modal' +import Modal, { ModalBody } from 'components/base/Modal' import GenuineCheck from 'components/GenuineCheck' type Props = { @@ -19,12 +19,13 @@ class GenuineCheckModal extends PureComponent { renderBody = ({ onClose }) => { const { t, onSuccess, onFail, onUnavailable } = this.props return ( - - {t('genuinecheck.modal.title')} - + ( - - + )} + /> ) } diff --git a/src/components/IsUnlocked.js b/src/components/IsUnlocked.js index 997ee69f..35dfbdca 100644 --- a/src/components/IsUnlocked.js +++ b/src/components/IsUnlocked.js @@ -23,8 +23,8 @@ import Box from 'components/base/Box' import InputPassword from 'components/base/InputPassword' import LedgerLiveLogo from 'components/base/LedgerLiveLogo' import IconArrowRight from 'icons/ArrowRight' -import Button from './base/Button/index' -import ConfirmModal from './base/Modal/ConfirmModal' +import Button from 'components/base/Button/index' +import ConfirmModal from 'components/base/Modal/ConfirmModal' type InputValue = { password: string, diff --git a/src/components/MainSideBar/index.js b/src/components/MainSideBar/index.js index 8fa91bf5..4ada30c9 100644 --- a/src/components/MainSideBar/index.js +++ b/src/components/MainSideBar/index.js @@ -11,20 +11,19 @@ import type { Location } from 'react-router' import type { Account } from '@ledgerhq/live-common/lib/types' import type { T } from 'types/common' -import type { UpdateStatus } from 'reducers/update' import { MODAL_RECEIVE, MODAL_SEND, MODAL_ADD_ACCOUNTS } from 'config/constants' import { i } from 'helpers/staticPath' import { accountsSelector } from 'reducers/accounts' import { openModal } from 'reducers/modals' -import { getUpdateStatus } from 'reducers/update' import { developerModeSelector } from 'reducers/settings' import { SideBarList, SideBarListItem } from 'components/base/SideBar' import Box from 'components/base/Box' import GrowScroll from 'components/base/GrowScroll' import Space from 'components/base/Space' +import UpdateDot from 'components/Updater/UpdateDot' import IconManager from 'icons/Manager' import IconPieChart from 'icons/PieChart' @@ -39,7 +38,6 @@ import KeyboardContent from '../KeyboardContent' const mapStateToProps = state => ({ accounts: accountsSelector(state), - updateStatus: getUpdateStatus(state), developerMode: developerModeSelector(state), }) @@ -54,7 +52,6 @@ type Props = { location: Location, push: string => void, openModal: string => void, - updateStatus: UpdateStatus, developerMode: boolean, } @@ -96,7 +93,7 @@ class MainSideBar extends PureComponent { handleOpenImportModal = () => this.props.openModal(MODAL_ADD_ACCOUNTS) render() { - const { t, accounts, location, updateStatus, developerMode } = this.props + const { t, accounts, location, developerMode } = this.props const { pathname } = location const addAccountButton = ( @@ -122,7 +119,7 @@ class MainSideBar extends PureComponent { iconActiveColor="wallet" onClick={this.handleClickDashboard} isActive={pathname === '/'} - hasNotif={updateStatus === 'downloaded'} + NotifComponent={UpdateDot} /> ({ isDevMode: developerModeSelector(state), @@ -39,15 +39,35 @@ const mapStateToProps = state => ({ const List = styled(Box).attrs({ horizontal: true, - m: -3, })` flex-wrap: wrap; + + > * { + width: calc(50% - 10px); + margin-bottom: 20px; + &:nth-child(even) { + margin-left: 20px; + } + + @media (max-width: 1000px) { + width: 100%; + &:nth-child(even) { + margin-left: 0; + } + } + } ` const ICONS_FALLBACK = { bitcoin_testnet: 'bitcoin', } +const CATALOG_INFO_ICON = ( + + + +) + type Status = 'loading' | 'idle' | 'busy' | 'success' | 'error' type Mode = 'home' | 'installing' | 'uninstalling' @@ -79,6 +99,20 @@ const LoadingApp = () => ( const loadingApp = +const FAKE_LIST = ( + + {loadingApp} + {loadingApp} + {loadingApp} + {loadingApp} + {loadingApp} + {loadingApp} + {loadingApp} + {loadingApp} + {loadingApp} + +) + class AppsList extends PureComponent { state = { status: 'loading', @@ -150,108 +184,103 @@ class AppsList extends PureComponent { handleCloseModal = () => this.setState({ status: 'idle', mode: 'home' }) - renderModal = () => { + renderBody = () => { const { t } = this.props const { app, status, error, mode, progress } = this.state - return ( - ( - - - {status === 'busy' || status === 'idle' ? ( - - - {mode === 'installing' ? ( - - - - ) : ( - - - - )} - - - - {t(`manager.apps.${mode}`, { app })} - - - - - - - ) : status === 'error' ? ( - - - - - - - - - - - - - - - - - - ) : status === 'success' ? ( - - - - - - - {t( - `manager.apps.${ - mode === 'installing' ? 'installSuccess' : 'uninstallSuccess' - }`, - { app }, - )} - - - - - - - ) : null} - + + return ['busy', 'idle'].includes(status) ? ( + + {mode === 'installing' ? ( + + + + ) : ( + + + )} - /> + + {t(`manager.apps.${mode}`, { app })} + + + + + + ) : status === 'error' ? ( + + + + + + + + + + + + + + + ) : status === 'success' ? ( + + + + + + {t(`manager.apps.${mode === 'installing' ? 'installSuccess' : 'uninstallSuccess'}`, { + app, + })} + + + ) : null + } + + renderFooter = () => { + const { t } = this.props + return ( + + + + ) + } + + renderModal = () => { + const { status } = this.state + return ( + + + + + ) } @@ -276,47 +305,30 @@ class AppsList extends PureComponent { )} {this.renderModal()} - {!appsLoaded && ( - - - - {loadingApp} - {loadingApp} - {loadingApp} - {loadingApp} - {loadingApp} - {loadingApp} - {loadingApp} - {loadingApp} - {loadingApp} - - - )} + {!appsLoaded && FAKE_LIST} + + ) + } + + renderTooltip = () => { + const { t } = this.props + return ( + + {t('manager.apps.help')} ) } render() { const { t } = this.props + return ( - - - - {t('manager.apps.all')} - ( - - {t('manager.apps.help')} - - )} - > - - - - - - {this.renderList()} + + + {t('manager.apps.all')} + {CATALOG_INFO_ICON} + {this.renderList()} ) } diff --git a/src/components/ManagerPage/ManagerApp.js b/src/components/ManagerPage/ManagerApp.js index 1f214de8..a5de94d4 100644 --- a/src/components/ManagerPage/ManagerApp.js +++ b/src/components/ManagerPage/ManagerApp.js @@ -14,15 +14,12 @@ import Button from 'components/base/Button' export const Container = styled(Box).attrs({ horizontal: true, - my: 2, - mx: 3, p: 4, bg: 'white', boxShadow: p => (p.noShadow ? -1 : 0), borderRadius: 4, flow: 2, })` - width: calc(50% - 30px); line-height: normal; ` diff --git a/src/components/Onboarding/steps/GenuineCheck/GenuineCheckUnavailable.js b/src/components/Onboarding/steps/GenuineCheck/GenuineCheckUnavailable.js index b5a0023a..727d59f3 100644 --- a/src/components/Onboarding/steps/GenuineCheck/GenuineCheckUnavailable.js +++ b/src/components/Onboarding/steps/GenuineCheck/GenuineCheckUnavailable.js @@ -31,6 +31,8 @@ export function GenuineCheckUnavailableFooter({ - ) diff --git a/src/components/OperationsList/AccountCell.js b/src/components/OperationsList/AccountCell.js index 1e5e50cf..27f4d6f2 100644 --- a/src/components/OperationsList/AccountCell.js +++ b/src/components/OperationsList/AccountCell.js @@ -3,7 +3,7 @@ import React, { PureComponent } from 'react' import styled from 'styled-components' import { getCryptoCurrencyIcon } from '@ledgerhq/live-common/lib/react' -import type { Currency } from '@ledgerhq/live-common/lib/types' +import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types' import Box from 'components/base/Box' const Cell = styled(Box).attrs({ @@ -17,7 +17,7 @@ const Cell = styled(Box).attrs({ ` type Props = { - currency: Currency, + currency: CryptoCurrency, accountName: string, } diff --git a/src/components/OperationsList/AmountCell.js b/src/components/OperationsList/AmountCell.js index ac332fa2..84ec3abc 100644 --- a/src/components/OperationsList/AmountCell.js +++ b/src/components/OperationsList/AmountCell.js @@ -3,7 +3,7 @@ import React, { PureComponent } from 'react' import styled from 'styled-components' import { getOperationAmountNumber } from '@ledgerhq/live-common/lib/operation' -import type { Currency, Unit, Operation } from '@ledgerhq/live-common/lib/types' +import type { CryptoCurrency, Unit, Operation } from '@ledgerhq/live-common/lib/types' import Box from 'components/base/Box' import CounterValue from 'components/CounterValue' import FormattedVal from 'components/base/FormattedVal' @@ -18,7 +18,7 @@ const Cell = styled(Box).attrs({ type Props = { operation: Operation, - currency: Currency, + currency: CryptoCurrency, unit: Unit, } diff --git a/src/components/SelectExchange.js b/src/components/SelectExchange.js index e66cfb84..9424d5f7 100644 --- a/src/components/SelectExchange.js +++ b/src/components/SelectExchange.js @@ -87,7 +87,7 @@ class SelectExchange extends Component< this.setState({ exchanges, isLoading: false }) } } catch (error) { - logger.error(error) + logger.critical(error) if (!this._unmounted && this._loadId === _loadId) { this.setState({ error, isLoading: false }) } diff --git a/src/components/SettingsPage/CleanButton.js b/src/components/SettingsPage/CleanButton.js index 0f72d21f..8a3081ea 100644 --- a/src/components/SettingsPage/CleanButton.js +++ b/src/components/SettingsPage/CleanButton.js @@ -7,7 +7,7 @@ import logger from 'logger' import type { T } from 'types/common' import { cleanAccountsCache } from 'actions/accounts' import Button from 'components/base/Button' -import { ConfirmModal } from 'components/base/Modal' +import ConfirmModal from 'components/base/Modal/ConfirmModal' import { softReset } from 'helpers/reset' import ResetFallbackModal from './ResetFallbackModal' @@ -60,6 +60,7 @@ class CleanButton extends PureComponent { ({ value: fiat.code, - label: `${fiat.name} - ${fiat.code}${fiat.symbol ? ` (${fiat.symbol})` : ''}`, + label: `${fiat.name} - ${fiat.code}`, fiat, })) diff --git a/src/components/SettingsPage/DisablePasswordModal.js b/src/components/SettingsPage/DisablePasswordModal.js index b9613c42..89d04a01 100644 --- a/src/components/SettingsPage/DisablePasswordModal.js +++ b/src/components/SettingsPage/DisablePasswordModal.js @@ -8,7 +8,8 @@ import Box from 'components/base/Box' import Button from 'components/base/Button' import InputPassword from 'components/base/InputPassword' import Label from 'components/base/Label' -import { Modal, ModalContent, ModalBody, ModalTitle, ModalFooter } from 'components/base/Modal' +import Modal from 'components/base/Modal' +import ModalBody from 'components/base/Modal/ModalBody' import type { T } from 'types/common' @@ -61,37 +62,33 @@ class DisablePasswordModal extends PureComponent { const { t, onClose, ...props } = this.props const { currentPassword, incorrectPassword } = this.state return ( - ( -
- - - {t('password.disablePassword.title')} - - - - {t('password.disablePassword.desc')} - - - - - + + + ( + + {t('password.disablePassword.desc')} + + + + - - + + )} + renderFooter={() => ( + @@ -103,11 +100,11 @@ class DisablePasswordModal extends PureComponent { > {t('common.save')} - - -
- )} - /> +
+ )} + /> + +
) } } diff --git a/src/components/SettingsPage/PasswordAutoLockSelect.js b/src/components/SettingsPage/PasswordAutoLockSelect.js index 556fa845..8b643caf 100644 --- a/src/components/SettingsPage/PasswordAutoLockSelect.js +++ b/src/components/SettingsPage/PasswordAutoLockSelect.js @@ -30,10 +30,10 @@ class PasswordAutoLockSelect extends PureComponent { } timeouts = [ - { value: 1, label: `1 ${this.props.t('app:time.minute')}` }, - { value: 10, label: `10 ${this.props.t('app:time.minute')}s` }, - { value: 30, label: `30 ${this.props.t('app:time.minute')}s` }, - { value: 60, label: `1 ${this.props.t('app:time.hour')}` }, + { value: 1, label: `1 ${this.props.t('time.minute')}` }, + { value: 10, label: `10 ${this.props.t('time.minute')}s` }, + { value: 30, label: `30 ${this.props.t('time.minute')}s` }, + { value: 60, label: `1 ${this.props.t('time.hour')}` }, { value: -1, label: this.props.t(`app:common.never`) }, ] diff --git a/src/components/SettingsPage/PasswordForm.js b/src/components/SettingsPage/PasswordForm.js index d0587fec..ff6a4a03 100644 --- a/src/components/SettingsPage/PasswordForm.js +++ b/src/components/SettingsPage/PasswordForm.js @@ -70,6 +70,7 @@ class PasswordForm extends PureComponent { void, onChangePassword: (?string) => void, hasPassword: boolean, + isOpened: boolean, } type State = { @@ -36,6 +37,12 @@ const INITIAL_STATE = { class PasswordModal extends PureComponent { state = INITIAL_STATE + componentWillReceiveProps(nextProps: Props) { + if (!nextProps.isOpened) { + this.setState(INITIAL_STATE) + } + } + handleSave = (e: SyntheticEvent) => { const { currentPassword, newPassword } = this.state @@ -73,23 +80,17 @@ class PasswordModal extends PureComponent { } render() { - const { t, hasPassword, onClose, ...props } = this.props + const { t, hasPassword, onClose, isOpened, ...props } = this.props const { currentPassword, newPassword, incorrectPassword, confirmPassword } = this.state return ( - ( - - {hasPassword ? ( - {t('password.changePassword.title')} - ) : ( - - {t('password.setPassword.title')} - - )} - + + ( + { onChange={this.handleInputChange} t={t} /> - - +
+ )} + renderFooter={() => ( + - - - )} - /> + + )} + /> + ) } } diff --git a/src/components/SettingsPage/RepairDeviceButton.js b/src/components/SettingsPage/RepairDeviceButton.js index 511bed6e..945a5678 100644 --- a/src/components/SettingsPage/RepairDeviceButton.js +++ b/src/components/SettingsPage/RepairDeviceButton.js @@ -6,11 +6,12 @@ import { connect } from 'react-redux' import { withRouter } from 'react-router' import { translate } from 'react-i18next' import { push } from 'react-router-redux' +import logger from 'logger' import type { T } from 'types/common' import firmwareRepair from 'commands/firmwareRepair' import Button from 'components/base/Button' -import { RepairModal } from 'components/base/Modal' +import RepairModal from 'components/base/Modal/RepairModal' type Props = { t: T, @@ -32,27 +33,38 @@ class RepairDeviceButton extends PureComponent { progress: 0, } + componentWillUnmount() { + if (this.timeout) { + clearTimeout(this.timeout) + } + } + open = () => this.setState({ opened: true, error: null }) sub: * + timeout: * close = () => { if (this.sub) this.sub.unsubscribe() + if (this.timeout) clearTimeout(this.timeout) this.setState({ opened: false, isLoading: false, error: null, progress: 0 }) } repair = (version = null) => { if (this.state.isLoading) return const { push } = this.props - this.setState({ isLoading: true }) + this.timeout = setTimeout(() => this.setState({ isLoading: true }), 500) this.sub = firmwareRepair.send({ version }).subscribe({ next: patch => { this.setState(patch) }, error: error => { + logger.critical(error) + if (this.timeout) clearTimeout(this.timeout) this.setState({ error, isLoading: false, progress: 0 }) }, complete: () => { + if (this.timeout) clearTimeout(this.timeout) this.setState({ opened: false, isLoading: false, progress: 0 }, () => { push('/manager') }) diff --git a/src/components/SettingsPage/ResetButton.js b/src/components/SettingsPage/ResetButton.js index ba6745b5..2708c75e 100644 --- a/src/components/SettingsPage/ResetButton.js +++ b/src/components/SettingsPage/ResetButton.js @@ -9,7 +9,7 @@ import type { T } from 'types/common' import { hardReset } from 'helpers/reset' import Box from 'components/base/Box' import Button from 'components/base/Button' -import { ConfirmModal } from 'components/base/Modal' +import ConfirmModal from 'components/base/Modal/ConfirmModal' import IconTriangleWarning from 'icons/TriangleWarning' import ResetFallbackModal from './ResetFallbackModal' @@ -58,6 +58,7 @@ class ResetButton extends PureComponent { { + state = { + active: false, + } + + componentWillMount() { + this.resetServer() + } + + componentDidUpdate() { + if (!this.state.active) return + if (!this.server) { + this.resetServer() + } + } + + componentWillUnmount() { + if (this.server) this.server.close() + } + + resetServer = () => { + this.server = new WebSocket.Server({ port: 1234 }) + + const { accounts, settings } = this.props + + const data = encode({ + accounts, + settings, + exporterName: 'desktop', + exporterVersion: __APP_VERSION__, + }) + + // Secret handshake to avoid intruders + this.secret = Math.random() + .toString(36) + .slice(2) + + if (this.server) { + this.server.on('connection', ws => { + ws.on('message', message => { + if (message === this.secret) { + ws.send(data) + ws.close() + this.setState({ active: false }) + this.server = undefined + } + }) + }) + } + } + + secret: string + server: * + canvas = React.createRef() + + render() { + return this.state.active ? ( + + ) : ( + + ) + } +} + +export default connect(mapStateToProps)(SocketExport) diff --git a/src/components/SettingsPage/sections/Display.js b/src/components/SettingsPage/sections/Display.js index 0335c741..176cb629 100644 --- a/src/components/SettingsPage/sections/Display.js +++ b/src/components/SettingsPage/sections/Display.js @@ -91,8 +91,8 @@ class TabGeneral extends PureComponent { {hasPassword ? ( diff --git a/src/components/SettingsPage/sections/Export.js b/src/components/SettingsPage/sections/Export.js index 17932932..1680a370 100644 --- a/src/components/SettingsPage/sections/Export.js +++ b/src/components/SettingsPage/sections/Export.js @@ -8,13 +8,16 @@ import type { T } from 'types/common' import TrackPage from 'analytics/TrackPage' import styled from 'styled-components' import { SettingsSection as Section, SettingsSectionHeader as Header } from '../SettingsSection' +import { EXPERIMENTAL_WS_EXPORT } from '../../../config/constants' import IconShare from '../../../icons/Share' import Button from '../../base/Button' -import Modal, { ModalBody, ModalContent, ModalFooter, ModalTitle } from '../../base/Modal' +import Modal from '../../base/Modal' +import ModalBody from '../../base/Modal/ModalBody' import Box from '../../base/Box' import QRCodeExporter from '../../QRCodeExporter' import { BulletRow } from '../../Onboarding/helperComponents' import Text from '../../base/Text' +import SocketExport from '../SocketExport' const BulletRowIcon = styled(Box).attrs({ ff: 'Rubik|Regular', @@ -66,7 +69,10 @@ class SectionExport extends PureComponent { {'+'} - {'button in Accounts'} + {'button in'} + + {'Accounts'} + ), @@ -90,34 +96,45 @@ class SectionExport extends PureComponent { icon: {'3'}, desc: ( - + + {'Scan the'} + + {'LiveQR Code'} + + {'until the loader hits 100%'} + ), }, ] return ( - - {t('settings.export.modal.title')} - - - - - - - {t('settings.export.modal.listTitle')} - + ( + + + + + + + {t('settings.export.modal.listTitle')} + + + + {stepsImportMobile.map(step => )} + - - {stepsImportMobile.map(step => )} + )} + renderFooter={() => ( + + - - - - - + )} + /> ) } @@ -131,7 +148,7 @@ class SectionExport extends PureComponent {
} - title={t('settings.tabs.export')} + title={t('settings.export.title')} desc={t('settings.export.desc')} renderRight={ } /> + {EXPERIMENTAL_WS_EXPORT && ( +
} + title="Experimental websocket local export ⚡" + desc="Generate a pairing code and use it on Ledger Live Mobile" + renderRight={} + /> + )} ) diff --git a/src/components/TopBanner.js b/src/components/TopBanner.js new file mode 100644 index 00000000..bad4c141 --- /dev/null +++ b/src/components/TopBanner.js @@ -0,0 +1,130 @@ +// @flow + +import React, { PureComponent } from 'react' +import styled from 'styled-components' +import { connect } from 'react-redux' +import Box from 'components/base/Box' +import { radii } from 'styles/theme' +import IconCross from 'icons/Cross' +import { createStructuredSelector } from 'reselect' +import { dismissBanner } from '../actions/settings' +import { dismissedBannersSelector } from '../reducers/settings' + +export type Content = { + Icon?: React$ComponentType<*>, + message: React$Node, + right?: React$Node, +} + +type Props = { + content?: Content, + status: string, + dismissable: boolean, + bannerId?: string, + dismissedBanners: string[], + dismissBanner: string => void, +} + +const mapStateToProps = createStructuredSelector({ + dismissedBanners: dismissedBannersSelector, +}) + +const mapDispatchToProps = { + dismissBanner, +} + +class TopBanner extends PureComponent { + static defaultProps = { + status: '', + dismissable: false, + } + + onDismiss = () => { + const { bannerId, dismissBanner } = this.props + + if (bannerId) { + dismissBanner(bannerId) + } + } + + render() { + const { dismissedBanners, bannerId, dismissable, content, status } = this.props + + if (!content || (bannerId && dismissedBanners.includes(bannerId))) return null + + const { Icon, message, right } = content + + return ( + + {Icon && ( + + + + )} + {message} + {right} + {dismissable && ( + + + + )} + + ) + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(TopBanner) + +const IconContainer = styled.div` + margin-right: 15px; + display: flex; + align-items: center; +` + +const colorForStatus = { + error: 'alertRed', + dark: '#142533', +} + +const Container = styled(Box).attrs({ + horizontal: true, + align: 'center', + py: '8px', + px: 3, + bg: p => colorForStatus[p.status] || 'wallet', + color: 'white', + mt: -20, + mb: 20, + fontSize: 4, + ff: 'Open Sans|SemiBold', +})` + border-radius: ${radii[1]}px; +` + +const RightContainer = styled.div` + margin-left: auto; +` + +export const FakeLink = styled.span` + color: white; + text-decoration: underline; + cursor: pointer; +` + +const CloseContainer = styled(Box).attrs({ + color: 'white', +})` + z-index: 1; + margin-left: 10px; + cursor: pointer; + &:hover { + color: #eee; + } + + &:active { + color: #eee; + } +` diff --git a/src/components/TopBar/index.js b/src/components/TopBar/index.js index b18914c5..c8f21afc 100644 --- a/src/components/TopBar/index.js +++ b/src/components/TopBar/index.js @@ -42,9 +42,7 @@ const Inner = styled(Box).attrs({ grow: true, flow: 4, align: 'center', -})` - border-bottom: 1px solid ${p => p.theme.colors.fog}; -` +})`` const Bar = styled.div` margin-left: 5px; diff --git a/src/components/UpdateNotifier/UpdateDownloaded.js b/src/components/UpdateNotifier/UpdateDownloaded.js deleted file mode 100644 index 67411d13..00000000 --- a/src/components/UpdateNotifier/UpdateDownloaded.js +++ /dev/null @@ -1,103 +0,0 @@ -// @flow - -import React, { PureComponent } from 'react' -import { translate } from 'react-i18next' -import { compose } from 'redux' -import { connect } from 'react-redux' -import styled from 'styled-components' - -import { getUpdateStatus, getUpdateData } from 'reducers/update' -import { sendEvent } from 'renderer/events' -import type { State } from 'reducers' -import type { UpdateStatus } from 'reducers/update' - -import { radii } from 'styles/theme' - -import Box from 'components/base/Box' -import Text from 'components/base/Text' - -import UpdateIcon from 'icons/Update' - -import type { T } from 'types/common' - -type Props = { - t: T, - updateStatus: UpdateStatus, -} - -const mapStateToProps = (state: State) => ({ - updateStatus: getUpdateStatus(state), - updateData: getUpdateData(state), -}) - -const Container = styled(Box).attrs({ - py: '8px', - px: 3, - bg: 'wallet', - color: 'white', - mt: '-50px', - mb: '35px', - style: p => ({ - transform: `translate3d(0, ${p.offset}%, 0)`, - }), -})` - border-radius: ${radii[1]}px; -` - -const NotifText = styled(Text).attrs({ - ff: 'Open Sans|SemiBold', - fontSize: 4, -})`` - -class UpdateDownloaded extends PureComponent { - renderStatus() { - const { updateStatus, t } = this.props - switch (updateStatus) { - case 'idle': - case 'checking': - case 'unavailable': - case 'error': - case 'available': - case 'progress': - return null - case 'downloaded': - return ( - - - - {t('update.newVersionReady')} - - - sendEvent('updater', 'quitAndInstall')} - > - {t('update.relaunch')} - - - - ) - default: - return null - } - } - - render() { - const { updateStatus, ...props } = this.props - - const isToggled = updateStatus === 'downloaded' - - if (!isToggled) { - return null - } - return {this.renderStatus()} - } -} - -export default compose( - connect( - mapStateToProps, - null, - ), - translate(), -)(UpdateDownloaded) diff --git a/src/components/UpdateNotifier/UpdateInstalled.js b/src/components/UpdateNotifier/UpdateInstalled.js deleted file mode 100644 index b0fbcdc5..00000000 --- a/src/components/UpdateNotifier/UpdateInstalled.js +++ /dev/null @@ -1,48 +0,0 @@ -// @flow - -import { PureComponent } from 'react' -import { connect } from 'react-redux' -import semver from 'semver' - -import { openModal } from 'reducers/modals' -import { lastUsedVersionSelector } from 'reducers/settings' -import { saveSettings } from 'actions/settings' -import { MODAL_RELEASES_NOTES } from 'config/constants' - -import type { State } from 'reducers' - -type Props = { - openModal: Function, - saveSettings: Function, - lastUsedVersion: string, -} - -const mapStateToProps = (state: State) => ({ - lastUsedVersion: lastUsedVersionSelector(state), -}) - -const mapDispatchToProps = { - openModal, - saveSettings, -} - -class UpdateInstalled extends PureComponent { - componentDidMount() { - const { lastUsedVersion, saveSettings, openModal } = this.props - const currentVersion = __APP_VERSION__ - - if (semver.gt(currentVersion, lastUsedVersion)) { - openModal(MODAL_RELEASES_NOTES, currentVersion) - saveSettings({ lastUsedVersion: currentVersion }) - } - } - - render() { - return null - } -} - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(UpdateInstalled) diff --git a/src/components/UpdateNotifier/index.js b/src/components/UpdateNotifier/index.js deleted file mode 100644 index 6ea65461..00000000 --- a/src/components/UpdateNotifier/index.js +++ /dev/null @@ -1,17 +0,0 @@ -// @flow - -import React, { PureComponent, Fragment } from 'react' - -import UpdateDownloaded from './UpdateDownloaded' -import UpdateInstalled from './UpdateInstalled' - -export default class UpdateNotifier extends PureComponent<{}> { - render() { - return ( - - - - - ) - } -} diff --git a/src/components/Updater/Banner.js b/src/components/Updater/Banner.js new file mode 100644 index 00000000..40758af4 --- /dev/null +++ b/src/components/Updater/Banner.js @@ -0,0 +1,74 @@ +// @flow + +import React, { PureComponent } from 'react' +import { Trans } from 'react-i18next' + +import { urls } from 'config/urls' +import { openURL } from 'helpers/linking' + +import Spinner from 'components/base/Spinner' +import IconUpdate from 'icons/Update' +import IconDonjon from 'icons/Donjon' +import IconWarning from 'icons/TriangleWarning' + +import { withUpdaterContext } from './UpdaterContext' +import type { UpdaterContextType } from './UpdaterContext' +import TopBanner, { FakeLink } from '../TopBanner' +import type { Content } from '../TopBanner' + +type Props = { + context: UpdaterContextType, +} + +export const VISIBLE_STATUS = ['download-progress', 'checking', 'check-success', 'error'] + +const CONTENT_BY_STATUS = (quitAndInstall, reDownload, progress): { [string]: Content } => ({ + 'download-progress': { + Icon: Spinner, + message: , + right: , + }, + checking: { + Icon: IconDonjon, + message: , + }, + 'check-success': { + Icon: IconUpdate, + message: , + right: ( + + + + ), + }, + error: { + Icon: IconWarning, + message: , + right: ( + + + + ), + }, +}) + +class UpdaterTopBanner extends PureComponent { + reDownload = () => { + openURL(urls.liveHome) + } + + render() { + const { context } = this.props + const { status, quitAndInstall, downloadProgress } = context + if (!VISIBLE_STATUS.includes(status)) return null + + const content: ?Content = CONTENT_BY_STATUS(quitAndInstall, this.reDownload, downloadProgress)[ + status + ] + if (!content) return null + + return + } +} + +export default withUpdaterContext(UpdaterTopBanner) diff --git a/src/components/Updater/DebugUpdater.js b/src/components/Updater/DebugUpdater.js new file mode 100644 index 00000000..32cf4c19 --- /dev/null +++ b/src/components/Updater/DebugUpdater.js @@ -0,0 +1,70 @@ +// @flow +/* eslint-disable react/jsx-no-literals */ + +import React, { Component } from 'react' + +import { withUpdaterContext } from './UpdaterContext' +import type { UpdaterContextType } from './UpdaterContext' + +const statusToDebug = ['idle', 'download-progress', 'checking', 'check-success', 'error'] + +type Props = { + context: UpdaterContextType, +} + +class DebugUpdater extends Component { + render() { + const { context } = this.props + const { status, setStatus, quitAndInstall } = context + return ( +
+

+ DEBUG UPDATE
+ ------------
+

+ status: {status} +
+ {statusToDebug.map(s => ( + + ))} +
+
+ simulate update +
+
+ +
+
+ ) + } +} + +const styles = { + root: { + position: 'fixed', + bottom: 0, + right: 0, + padding: 10, + fontSize: 10, + background: 'black', + color: 'white', + fontFamily: 'monospace', + zIndex: 1000, + maxWidth: 250, + }, + btn: { + cursor: 'pointer', + background: 'lightgreen', + color: 'black', + border: 'none', + marginRight: 10, + marginTop: 10, + padding: '0px 10px', + }, +} + +export default withUpdaterContext(DebugUpdater) diff --git a/src/components/Updater/UpdateDot.js b/src/components/Updater/UpdateDot.js new file mode 100644 index 00000000..65a81a98 --- /dev/null +++ b/src/components/Updater/UpdateDot.js @@ -0,0 +1,37 @@ +// @flow + +import React from 'react' +import styled from 'styled-components' + +import { colors } from 'styles/theme' + +import { withUpdaterContext } from './UpdaterContext' +import { VISIBLE_STATUS } from './Banner' +import type { UpdaterContextType, UpdateStatus } from './UpdaterContext' + +type Props = { + context: UpdaterContextType, +} + +const getColor = ({ status }: { status: UpdateStatus }) => + status === 'error' ? colors.alertRed : colors.wallet + +const getOpacity = ({ status }: { status: UpdateStatus }) => + status === 'download-progress' || status === 'checking' ? 0.5 : 1 + +const Dot = styled.div` + opacity: ${getOpacity}; + width: 8px; + height: 8px; + background-color: ${getColor}; + border-radius: 50%; +` + +function UpdateDot(props: Props) { + const { context } = props + const { status } = context + if (!VISIBLE_STATUS.includes(status)) return null + return +} + +export default withUpdaterContext(UpdateDot) diff --git a/src/components/Updater/UpdaterContext.js b/src/components/Updater/UpdaterContext.js new file mode 100644 index 00000000..91351a48 --- /dev/null +++ b/src/components/Updater/UpdaterContext.js @@ -0,0 +1,95 @@ +// @flow + +import React, { Component } from 'react' + +import autoUpdate from 'commands/autoUpdate' +import quitAndInstallElectronUpdate from 'commands/quitAndInstallElectronUpdate' + +export type UpdateStatus = + | 'idle' + | 'checking-for-update' + | 'update-available' + | 'update-not-available' + | 'download-progress' + | 'update-downloaded' + | 'checking' + | 'check-success' + | 'error' + +export type UpdaterContextType = { + status: UpdateStatus, + downloadProgress: number, + quitAndInstall: () => void, + setStatus: UpdateStatus => void, + error: ?Error, +} + +type UpdaterProviderProps = { + children: *, +} + +type UpdaterProviderState = { + status: UpdateStatus, + downloadProgress: number, + error: ?Error, +} + +const UpdaterContext = React.createContext() + +class Provider extends Component { + constructor() { + super() + + if (__PROD__) { + this.sub = autoUpdate.send({}).subscribe({ + next: e => { + if (e.status === 'download-progress') { + const downloadProgress = e.payload && e.payload.percent ? e.payload.percent : 0 + this.setState({ status: e.status, downloadProgress }) + } else { + this.setStatus(e.status) + } + }, + error: error => this.setState({ status: 'error', error }), + }) + } + + this.state = { + status: 'idle', + downloadProgress: 0, + error: null, + } + } + + componentWillUnmount() { + if (this.sub) { + this.sub.unsubscribe() + } + } + + sub = null + + setStatus = (status: UpdateStatus) => this.setState({ status }) + setDownloadProgress = (downloadProgress: number) => this.setState({ downloadProgress }) + quitAndInstall = () => quitAndInstallElectronUpdate.send().toPromise() + + render() { + const { status, downloadProgress, error } = this.state + const value = { + status, + downloadProgress, + error, + setStatus: this.setStatus, + quitAndInstall: this.quitAndInstall, + } + return {this.props.children} + } +} + +export const withUpdaterContext = (ComponentToDecorate: React$ComponentType<*>) => (props: *) => ( + + {context => } + +) + +export const UpdaterProvider = Provider diff --git a/src/components/WithFeesAPI.js b/src/components/WithFeesAPI.js index 5a56a347..09b355e0 100644 --- a/src/components/WithFeesAPI.js +++ b/src/components/WithFeesAPI.js @@ -1,13 +1,13 @@ // @flow import { Component } from 'react' -import type { Currency } from '@ledgerhq/live-common/lib/types' -import { getEstimatedFees } from 'api/Fees' -import type { Fees } from 'api/Fees' +import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types' +import { getEstimatedFees } from '@ledgerhq/live-common/lib/api/Fees' +import type { Fees } from '@ledgerhq/live-common/lib/api/Fees' // FIXME we need to abstract this out like we did for CounterValues export default class WithFeesAPI extends Component< { - currency: Currency, + currency: CryptoCurrency, render: Fees => *, renderLoading: () => *, renderError: Error => *, diff --git a/src/components/base/CurrencyBadge.js b/src/components/base/CurrencyBadge.js index 08a27ff5..ab5ef0ff 100644 --- a/src/components/base/CurrencyBadge.js +++ b/src/components/base/CurrencyBadge.js @@ -4,7 +4,7 @@ import React from 'react' import styled from 'styled-components' import { getCryptoCurrencyIcon } from '@ledgerhq/live-common/lib/react' -import type { Currency } from '@ledgerhq/live-common/lib/types' +import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types' import { rgba } from 'styles/helpers' @@ -26,7 +26,7 @@ export function CurrencyCircleIcon({ size, ...props }: { - currency: Currency, + currency: CryptoCurrency, size: number, }) { const Icon = getCryptoCurrencyIcon(currency) @@ -37,7 +37,7 @@ export function CurrencyCircleIcon({ ) } -function CurrencyBadge({ currency, ...props }: { currency: Currency }) { +function CurrencyBadge({ currency, ...props }: { currency: CryptoCurrency }) { return ( diff --git a/src/components/base/Modal/ConfirmModal.js b/src/components/base/Modal/ConfirmModal.js index 0512a981..416eb5a7 100644 --- a/src/components/base/Modal/ConfirmModal.js +++ b/src/components/base/Modal/ConfirmModal.js @@ -9,7 +9,8 @@ import TrackPage from 'analytics/TrackPage' import Button from 'components/base/Button' import Box from 'components/base/Box' -import { Modal, ModalContent, ModalBody, ModalTitle, ModalFooter } from './index' +import Modal from './index' +import ModalBody from './ModalBody' type Props = { isOpened: boolean, @@ -21,11 +22,13 @@ type Props = { confirmText?: string, cancelText?: string, onReject: Function, + onClose?: Function, onConfirm: Function, t: T, isLoading?: boolean, analyticsName: string, cancellable?: boolean, + centered?: boolean, } class ConfirmModal extends PureComponent { @@ -43,23 +46,38 @@ class ConfirmModal extends PureComponent { onConfirm, isLoading, renderIcon, + onClose, t, analyticsName, + centered, ...props } = this.props const realConfirmText = confirmText || t('common.confirm') const realCancelText = cancelText || t('common.cancel') return ( - ( - - - {title} - + + ( + + {!isLoading && } + + + )} + render={() => ( + {subTitle && ( {subTitle} @@ -73,22 +91,11 @@ class ConfirmModal extends PureComponent { {desc} - - - {!isLoading && } - - - - )} - /> + + )} + /> + +
) } } diff --git a/src/components/base/Modal/ModalBody.js b/src/components/base/Modal/ModalBody.js index c0083590..aa6642f1 100644 --- a/src/components/base/Modal/ModalBody.js +++ b/src/components/base/Modal/ModalBody.js @@ -1,95 +1,102 @@ // @flow -import React, { PureComponent } from 'react' -import styled, { keyframes } from 'styled-components' +import React, { PureComponent, Fragment } from 'react' +import Animated from 'animated/lib/targets/react-dom' +import { findDOMNode } from 'react-dom' -import Box from 'components/base/Box' -import IconCross from 'icons/Cross' +import ModalContent from './ModalContent' +import ModalHeader from './ModalHeader' +import ModalFooter from './ModalFooter' -export const Container = styled(Box).attrs({ - px: 5, - pb: 5, -})`` +import type { RenderProps } from './index' type Props = { - deferHeight?: number, - onClose?: Function, - children: any, + title: string, + onBack?: void => void, + onClose?: void => void, + render?: (?RenderProps) => any, + renderFooter?: (?RenderProps) => any, + renderProps?: RenderProps, + noScroll?: boolean, + refocusWhenChange?: any, } type State = { - isHidden: boolean, + animGradient: Animated.Value, } class ModalBody extends PureComponent { - static defaultProps = { - onClose: undefined, + state = { + animGradient: new Animated.Value(0), } - state = { - isHidden: true, + componentDidUpdate(prevProps: Props) { + const shouldFocus = prevProps.refocusWhenChange !== this.props.refocusWhenChange + if (shouldFocus) { + if (this._content) { + const node = findDOMNode(this._content) // eslint-disable-line react/no-find-dom-node + if (node) { + // $FlowFixMe + node.focus() + } + } + } } - componentDidMount() { - setTimeout(() => { - window.requestAnimationFrame(() => { - this.setState({ isHidden: false }) - }) - }, 150) + _content = null + + animateGradient = (isScrollable: boolean) => { + const anim = { + duration: 150, + toValue: isScrollable ? 1 : 0, + } + Animated.timing(this.state.animGradient, anim).start() } render() { - const { children, onClose, deferHeight, ...props } = this.props - const { isHidden } = this.state + const { onBack, onClose, title, render, renderFooter, renderProps, noScroll } = this.props + const { animGradient } = this.state + + const gradientStyle = { + ...GRADIENT_STYLE, + opacity: animGradient, + } + return ( - - {onClose && ( - - - - )} - {(!isHidden || !deferHeight) && {children}} - + + + {title} + + (this._content = n)} + onIsScrollableChange={this.animateGradient} + noScroll={noScroll} + > + {render && render(renderProps)} + +
+ +
+ {renderFooter && {renderFooter(renderProps)}} +
) } } -const CloseContainer = styled(Box).attrs({ - p: 4, - color: 'fog', -})` - position: absolute; - top: 0; - right: 0; - z-index: 1; - - &:hover { - color: ${p => p.theme.colors.grey}; - } - - &:active { - color: ${p => p.theme.colors.dark}; - } -` - -const Body = styled(Box).attrs({ - bg: p => p.theme.colors.white, - relative: true, - borderRadius: 1, -})` - box-shadow: 0 10px 20px 0 rgba(0, 0, 0, 0.2); -` - -const appear = keyframes` - from { opacity: 0; } - to { opacity: 1; } -` +const GRADIENT_STYLE = { + background: 'linear-gradient(rgba(255, 255, 255, 0), #ffffff)', + height: 40, + position: 'absolute', + bottom: 0, + left: 0, + right: 20, +} -const Inner = styled(Box)` - animation: ${appear} 80ms linear; -` +const GRADIENT_WRAPPER_STYLE = { + height: 0, + position: 'relative', + pointerEvents: 'none', +} export default ModalBody diff --git a/src/components/base/Modal/ModalContent.js b/src/components/base/Modal/ModalContent.js new file mode 100644 index 00000000..7b74ddd1 --- /dev/null +++ b/src/components/base/Modal/ModalContent.js @@ -0,0 +1,59 @@ +// @flow + +/* eslint-disable jsx-a11y/no-noninteractive-tabindex */ + +import React, { PureComponent } from 'react' + +class ModalContent extends PureComponent<{ + children: any, + onIsScrollableChange: boolean => void, + noScroll?: boolean, +}> { + componentDidMount() { + window.requestAnimationFrame(() => { + if (this._isUnmounted) return + this.showHideGradient() + if (this._outer) { + const ro = new ResizeObserver(this.showHideGradient) + ro.observe(this._outer) + } + }) + } + + componentWillUnmount() { + this._isUnmounted = true + } + + _outer = null + _isUnmounted = false + + showHideGradient = () => { + if (!this._outer) return + const { onIsScrollableChange } = this.props + const isScrollable = this._outer.scrollHeight > this._outer.clientHeight + onIsScrollableChange(isScrollable) + } + + render() { + const { children, noScroll } = this.props + + const contentStyle = { + ...CONTENT_STYLE, + overflow: noScroll ? 'visible' : 'auto', + } + + return ( +
(this._outer = n)} tabIndex={0}> + {children} +
+ ) + } +} + +const CONTENT_STYLE = { + flexShrink: 1, + padding: 20, + paddingBottom: 40, +} + +export default ModalContent diff --git a/src/components/base/Modal/ModalFooter.js b/src/components/base/Modal/ModalFooter.js new file mode 100644 index 00000000..97f7c083 --- /dev/null +++ b/src/components/base/Modal/ModalFooter.js @@ -0,0 +1,18 @@ +// @flow + +import React from 'react' + +import { colors } from 'styles/theme' + +const MODAL_FOOTER_STYLE = { + display: 'flex', + justifyContent: 'flex-end', + borderTop: `2px solid ${colors.lightGrey}`, + padding: 20, +} + +const ModalFooter = ({ children }: { children: any }) => ( +
{children}
+) + +export default ModalFooter diff --git a/src/components/base/Modal/ModalHeader.js b/src/components/base/Modal/ModalHeader.js new file mode 100644 index 00000000..e3078e8d --- /dev/null +++ b/src/components/base/Modal/ModalHeader.js @@ -0,0 +1,93 @@ +// @flow + +import React from 'react' +import styled from 'styled-components' +import { translate } from 'react-i18next' + +import type { T } from 'types/common' + +import Box from 'components/base/Box' + +import IconAngleLeft from 'icons/AngleLeft' +import IconCross from 'icons/Cross' + +const MODAL_HEADER_STYLE = { + position: 'relative', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: 20, +} + +const ModalTitle = styled(Box).attrs({ + color: 'dark', + ff: 'Museo Sans|Regular', + fontSize: 6, + grow: true, + shrink: true, +})` + text-align: center; + line-height: 1; +` + +const iconAngleLeft = +const iconCross = + +const ModalHeaderAction = styled(Box).attrs({ + horizontal: true, + align: 'center', + fontSize: 3, + p: 4, + color: 'grey', +})` + position: absolute; + top: 0; + left: ${p => (p.right ? 'auto' : 0)}; + right: ${p => (p.right ? 0 : 'auto')}; + line-height: 0; + cursor: pointer; + + &:hover { + color: ${p => p.theme.colors.graphite}; + } + + &:active { + color: ${p => p.theme.colors.dark}; + } + + span { + border-bottom: 1px dashed transparent; + } + &:focus span { + border-bottom-color: inherit; + } +` + +const ModalHeader = ({ + children, + onBack, + onClose, + t, +}: { + children: any, + onBack: void => void, + onClose: void => void, + t: T, +}) => ( +
+ {onBack && ( + + {iconAngleLeft} + {t('common.back')} + + )} + {children} + {onClose && ( + + {iconCross} + + )} +
+) + +export default translate()(ModalHeader) diff --git a/src/components/base/Modal/ModalTitle.js b/src/components/base/Modal/ModalTitle.js deleted file mode 100644 index 55cf2fb4..00000000 --- a/src/components/base/Modal/ModalTitle.js +++ /dev/null @@ -1,75 +0,0 @@ -// @flow - -import React from 'react' -import styled from 'styled-components' -import { translate } from 'react-i18next' - -import type { T } from 'types/common' - -import Box from 'components/base/Box' -import IconAngleLeft from 'icons/AngleLeft' - -const Container = styled(Box).attrs({ - alignItems: 'center', - color: 'dark', - ff: 'Museo Sans|Regular', - fontSize: 6, - justifyContent: 'center', - p: 5, - relative: true, -})`` - -const Back = styled(Box).attrs({ - unstyled: true, - horizontal: true, - align: 'center', - color: 'grey', - ff: 'Open Sans', - fontSize: 3, - p: 4, -})` - position: absolute; - line-height: 1; - top: 0; - left: 0; - - &:hover { - color: ${p => p.theme.colors.graphite}; - } - - &:active { - color: ${p => p.theme.colors.dark}; - } - - span { - border-bottom: 1px dashed transparent; - } - &:focus span { - border-bottom-color: inherit; - } -` - -function ModalTitle({ - t, - onBack, - children, - ...props -}: { - t: T, - onBack: any => void, - children: any, -}) { - return ( - - {onBack && ( - - - {t('common.back')} - - )} - {children} - - ) -} - -export default translate()(ModalTitle) diff --git a/src/components/base/Modal/RepairModal.js b/src/components/base/Modal/RepairModal.js index a69d839c..bf01337e 100644 --- a/src/components/base/Modal/RepairModal.js +++ b/src/components/base/Modal/RepairModal.js @@ -17,7 +17,8 @@ import ProgressCircle from 'components/ProgressCircle' import TranslatedError from 'components/TranslatedError' import ExclamationCircleThin from 'icons/ExclamationCircleThin' -import { Modal, ModalContent, ModalBody, ModalTitle, ModalFooter } from './index' +import Modal from './index' +import ModalBody from './ModalBody' const Container = styled(Box).attrs({ alignItems: 'center', @@ -39,18 +40,18 @@ const Separator = styled(Box).attrs({ ` const DisclaimerStep = ({ desc }: { desc?: string }) => ( - + {desc ? ( {desc} ) : null} - + ) const FlashStep = ({ progress, t }: { progress: number, t: * }) => progress === 0 ? ( - + {'1.'} @@ -74,9 +75,9 @@ const FlashStep = ({ progress, t }: { progress: number, t: * }) => alt={t('manager.modal.mcuFirst')} /> - + ) : ( - + @@ -88,11 +89,11 @@ const FlashStep = ({ progress, t }: { progress: number, t: * }) => {t('manager.modal.mcuPin')} - + ) const ErrorStep = ({ error }: { error: Error }) => ( - + @@ -118,7 +119,7 @@ const ErrorStep = ({ error }: { error: Error }) => ( - + ) type Props = { @@ -177,38 +178,44 @@ class RepairModal extends PureComponent { return ( ( - - - {title} - {error ? ( - - ) : isLoading ? ( - - ) : ( - - )} - - {!isLoading && !error ? ( - - + + ) : null} + + )} + renderFooter={() => + !isLoading ? ( + + {error ? null : ( <> )} - - ) : null} - - )} - /> + + ) : null + } + /> + ) } } diff --git a/src/components/base/Modal/index.js b/src/components/base/Modal/index.js index cad43fda..ff0ae518 100644 --- a/src/components/base/Modal/index.js +++ b/src/components/base/Modal/index.js @@ -1,54 +1,28 @@ // @flow -/* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/no-static-element-interactions */ -/* eslint-disable react/no-multi-comp */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ -import React, { Component } from 'react' -import { findDOMNode } from 'react-dom' +import React, { PureComponent, Fragment } from 'react' +import { createPortal } from 'react-dom' import { connect } from 'react-redux' -import Mortal from 'react-mortal' -import styled from 'styled-components' import noop from 'lodash/noop' -import { EXPERIMENTAL_CENTER_MODAL } from 'config/constants' - -import { rgba } from 'styles/helpers' -import { radii } from 'styles/theme' +import Animated from 'animated/lib/targets/react-dom' +import Easing from 'animated/lib/Easing' import { closeModal, isModalOpened, getModalData } from 'reducers/modals' - -import Box from 'components/base/Box' -import GrowScroll from 'components/base/GrowScroll' +import { colors } from 'styles/theme' export { default as ModalBody } from './ModalBody' -export { default as ConfirmModal } from './ConfirmModal' -export { default as RepairModal } from './RepairModal' -export { default as ModalTitle } from './ModalTitle' -const springConfig = { - stiffness: 320, +const animShowHide = { + duration: 200, + easing: Easing.bezier(0.3, 1.0, 0.5, 0.8), } -type OwnProps = { - name?: string, // eslint-disable-line - isOpened?: boolean, - onBeforeOpen?: ({ data: * }) => *, // eslint-disable-line - onClose?: () => void, - onHide?: () => void, - preventBackdropClick?: boolean, - render: Function, - refocusWhenChange?: string, - width?: string, -} +const domNode = process.env.STORYBOOK_ENV ? document.body : document.getElementById('modals') -type Props = OwnProps & { - isOpened?: boolean, - data?: any, -} & { - onClose?: () => void, -} - -const mapStateToProps = (state, { name, isOpened, onBeforeOpen }: OwnProps): * => { +const mapStateToProps = (state, { name, isOpened, onBeforeOpen }: Props): * => { const data = getModalData(state, name || '') const modalOpened = isOpened || (name && isModalOpened(state, name)) @@ -62,7 +36,7 @@ const mapStateToProps = (state, { name, isOpened, onBeforeOpen }: OwnProps): * = } } -const mapDispatchToProps = (dispatch: *, { name, onClose = noop }: OwnProps): * => ({ +const mapDispatchToProps = (dispatch: *, { name, onClose = noop }: Props): * => ({ onClose: name ? () => { dispatch(closeModal(name)) @@ -71,178 +45,187 @@ const mapDispatchToProps = (dispatch: *, { name, onClose = noop }: OwnProps): * : onClose, }) -const Container = styled(Box).attrs({ - color: 'grey', - sticky: true, - style: p => ({ - pointerEvents: p.isVisible ? 'auto' : 'none', - }), -})` - position: fixed; - z-index: 30; -` - -const Backdrop = styled(Box).attrs({ - bg: p => rgba(p.theme.colors.black, 0.4), - sticky: true, - style: p => ({ - opacity: p.op, - }), -})` - position: fixed; -` - -const NonClickableHeadArea = styled.div` - position: fixed; - height: 48px; - width: 100%; - top: 0; - left: 0; - z-index: 1; -` - -const Wrapper = styled(Box).attrs({ - bg: 'transparent', - flow: 4, - style: p => ({ - opacity: p.op, - transform: `scale3d(${p.scale}, ${p.scale}, ${p.scale})`, - }), -})` - outline: none; - width: ${p => (p.width ? p.width : '500px')}; - z-index: 2; -` - -class Pure extends Component { - shouldComponentUpdate(nextProps) { - if (nextProps.isAnimated) { - return false - } - - return true - } +export type RenderProps = { + onClose?: void => void, + data: any, +} - render() { - const { data, onClose, render } = this.props +type Props = { + isOpened?: boolean, + children?: any, + centered?: boolean, + onClose?: void => void, + onHide?: void => void, + render?: RenderProps => any, + data?: any, + preventBackdropClick?: boolean, - return render({ data, onClose }) - } + name?: string, // eslint-disable-line + onBeforeOpen?: ({ data: * }) => *, // eslint-disable-line } -function stopPropagation(e) { - e.stopPropagation() +type State = { + animShowHide: Animated.Value, + isInDOM: boolean, } -const wrap = EXPERIMENTAL_CENTER_MODAL - ? children => ( - - {children} - - ) - : children => ( - - {children} - - ) +class Modal extends PureComponent { + state = { + animShowHide: new Animated.Value(0), + isInDOM: this.props.isOpened === true, + } -export class Modal extends Component { - static defaultProps = { - isOpened: false, - onHide: noop, - preventBackdropClick: false, + static getDerivedStateFromProps(nextProps: Props) { + const patch = {} + if (nextProps.isOpened) { + patch.isInDOM = true + } + return patch } - shouldComponentUpdate(nextProps: Props) { - if (this.props.isOpened || nextProps.isOpened) { - return true + componentDidMount() { + if (this.props.isOpened) { + this.animateEnter() } - return false + this.state.animShowHide.addListener(({ value }) => { + if (value === 0) { + const { onHide } = this.props + this.setState({ isInDOM: false }) + if (onHide) { + onHide() + } + } + if (value === 1) this.setState({ isInDOM: true }) + }) + + document.addEventListener('keyup', this.handleKeyup) } componentDidUpdate(prevProps: Props) { - const didOpened = this.props.isOpened && !prevProps.isOpened - const didClose = !this.props.isOpened && prevProps.isOpened - const shouldFocus = didOpened || this.props.refocusWhenChange !== prevProps.refocusWhenChange + const didOpened = !prevProps.isOpened && this.props.isOpened + const didClosed = prevProps.isOpened && !this.props.isOpened + if (didOpened) { - // Store a reference to the last active element, to restore it after - // modal close - this._lastFocusedElement = document.activeElement - } - if (shouldFocus) { - this.focusWrapper() + this.animateEnter() } - if (didClose) { - if (this._lastFocusedElement) { - this._lastFocusedElement.focus() - } + if (didClosed) { + this.animateLeave() } } - _wrapper = null - _lastFocusedElement = null + componentWillUnmount() { + document.removeEventListener('keyup', this.handleKeyup) + } + + handleKeyup = (e: KeyboardEvent) => { + const { onClose, preventBackdropClick } = this.props + if (e.which === 27 && onClose && !preventBackdropClick) { + onClose() + } + } - focusWrapper = () => { - // Forced to use findDOMNode here, because innerRef is giving a proxied component - const domWrapper = findDOMNode(this._wrapper) // eslint-disable-line react/no-find-dom-node - if (domWrapper instanceof HTMLDivElement && !domWrapper.contains(this._lastFocusedElement)) { - domWrapper.focus() + handleClickOnBackdrop = () => { + const { preventBackdropClick, onClose } = this.props + if (!preventBackdropClick && onClose) { + onClose() } } + swallowClick = e => { + e.preventDefault() + e.stopPropagation() + } + + animateEnter = () => + Animated.timing(this.state.animShowHide, { ...animShowHide, toValue: 1 }).start() + + animateLeave = () => + Animated.timing(this.state.animShowHide, { ...animShowHide, toValue: 0 }).start() + render() { - const { preventBackdropClick, isOpened, onHide, render, data, onClose, width } = this.props - - return ( - ({ - opacity: spring(isVisible ? 1 : 0, springConfig), - scale: spring(isVisible ? 1 : 0.95, springConfig), - })} - > - {(m, isVisible, isAnimated) => ( - - - - {wrap( - (this._wrapper = n)} - onClick={stopPropagation} - width={width} - > - - , - )} - - )} - + const { animShowHide, isInDOM } = this.state + const { children, render, centered, onClose, data, isOpened } = this.props + + if (!isInDOM) { + return null + } + + const backdropStyle = { + ...BACKDROP_STYLE, + opacity: animShowHide, + } + + const containerStyle = { + ...CONTAINER_STYLE, + justifyContent: centered ? 'center' : 'flex-start', + pointerEvents: isOpened ? 'auto' : 'none', + } + + const scale = animShowHide.interpolate({ + inputRange: [0, 1], + outputRange: [1.1, 1], + clamp: true, + }) + + const bodyWrapperStyle = { + ...BODY_WRAPPER_STYLE, + opacity: animShowHide, + transform: [{ scale }], + } + + const renderProps = { + onClose, + data, + } + + const modal = ( + + +
+ + {render && render(renderProps)} + {children} + +
+
) + + return domNode ? createPortal(modal, domNode) : null } } -export const ModalFooter = styled(Box).attrs({ - px: 5, - py: 3, -})` - border-top: 2px solid ${p => p.theme.colors.lightGrey}; - border-bottom-left-radius: ${radii[1]}px; - border-bottom-right-radius: ${radii[1]}px; -` - -export const ModalContent = styled(Box).attrs({ - px: 5, - pb: 5, - selectable: true, -})`` +const BACKDROP_STYLE = { + pointerEvents: 'none', + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + background: 'rgba(0, 0, 0, 0.4)', + zIndex: 100, +} + +const CONTAINER_STYLE = { + ...BACKDROP_STYLE, + background: 'transparent', + padding: '60px 0 60px 0', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', +} + +const BODY_WRAPPER_STYLE = { + background: 'white', + width: 500, + borderRadius: 3, + boxShadow: 'box-shadow: 0 10px 20px 0 rgba(0, 0, 0, 0.2)', + color: colors.smoke, + flexShrink: 1, + display: 'flex', + flexDirection: 'column', +} export default connect( mapStateToProps, diff --git a/src/components/base/Modal/stories.js b/src/components/base/Modal/stories.js index 9e946800..2eb46bb4 100644 --- a/src/components/base/Modal/stories.js +++ b/src/components/base/Modal/stories.js @@ -2,50 +2,57 @@ import React from 'react' import { storiesOf } from '@storybook/react' -import { action } from '@storybook/addon-actions' import { boolean, text } from '@storybook/addon-knobs' +import { action } from '@storybook/addon-actions' -import { - Modal, - ModalBody, - ModalTitle, - ModalContent, - ModalFooter, - ConfirmModal, -} from 'components/base/Modal' +import Modal from 'components/base/Modal' +import ModalBody from 'components/base/Modal/ModalBody' +import Input from 'components/base/Input' +import Label from 'components/base/Label' import Box from 'components/base/Box' -import Button from 'components/base/Button' const stories = storiesOf('Components/base', module) stories.add('Modal', () => ( ( - - {'modal title'} - {'this is the modal content'} - - {'modal footer'} - - - - )} - /> -)) - -stories.add('ConfirmModal', () => ( - ( + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + renderFooter={() => 'footer'} + /> )} - onConfirm={action('onConfirm')} - onReject={action('onReject')} /> )) diff --git a/src/components/base/SideBar/SideBarListItem.js b/src/components/base/SideBar/SideBarListItem.js index d50f0368..695af3bd 100644 --- a/src/components/base/SideBar/SideBarListItem.js +++ b/src/components/base/SideBar/SideBarListItem.js @@ -11,7 +11,7 @@ export type Props = { icon?: any, // TODO: type should be more precise, but, eh ¯\_(ツ)_/¯ disabled?: boolean, iconActiveColor: ?string, - hasNotif?: boolean, + NotifComponent?: React$ComponentType<*>, isActive?: boolean, onClick?: void => void, isActive?: boolean, @@ -24,7 +24,7 @@ class SideBarListItem extends PureComponent { label, desc, iconActiveColor, - hasNotif, + NotifComponent, onClick, isActive, disabled, @@ -47,7 +47,7 @@ class SideBarListItem extends PureComponent { )} {!!desc && desc(this.props)} - {!!hasNotif && } + {NotifComponent && } ) } @@ -85,11 +85,4 @@ const Container = styled(Tabbable).attrs({ }}; ` -const Bullet = styled.div` - background: ${p => p.theme.colors.wallet}; - width: 8px; - height: 8px; - border-radius: 100%; -` - export default SideBarListItem diff --git a/src/components/base/SideBar/stories.js b/src/components/base/SideBar/stories.js index 3f7297b0..edc9a0d1 100644 --- a/src/components/base/SideBar/stories.js +++ b/src/components/base/SideBar/stories.js @@ -33,7 +33,6 @@ const SIDEBAR_ITEMS = [ label: 'Third very very very very long text very long text very long', icon: IconControls, iconActiveColor: '#27a2db', - hasNotif: true, }, { key: 'fourth', diff --git a/src/components/base/Stepper/index.js b/src/components/base/Stepper/index.js index 8daf4acb..73511549 100644 --- a/src/components/base/Stepper/index.js +++ b/src/components/base/Stepper/index.js @@ -1,12 +1,12 @@ // @flow -import React, { PureComponent } from 'react' +import React, { PureComponent, Fragment } from 'react' import invariant from 'invariant' import { translate } from 'react-i18next' import type { T } from 'types/common' -import { ModalContent, ModalTitle, ModalFooter, ModalBody } from 'components/base/Modal' +import { ModalBody } from 'components/base/Modal' import Breadcrumb from 'components/Breadcrumb' type Props = { @@ -29,6 +29,7 @@ export type Step = { shouldRenderFooter?: StepProps => boolean, shouldPreventClose?: boolean | (StepProps => boolean), onBack?: StepProps => void, + noScroll?: boolean, } type State = { @@ -72,6 +73,7 @@ class Stepper extends PureComponent { onBack, shouldPreventClose, shouldRenderFooter, + noScroll, } = step const stepProps: StepProps = { @@ -89,25 +91,27 @@ class Stepper extends PureComponent { : !!shouldPreventClose return ( - - onBack(stepProps) : undefined}>{title} - - - - {children} - - {renderFooter && ( - - - + onBack(stepProps) : undefined} + title={title} + noScroll={noScroll} + render={() => ( + + + + {children} + )} - + renderFooter={renderFooter ? () => : undefined} + /> ) } } diff --git a/src/components/layout/Default.js b/src/components/layout/Default.js index 02830da4..14156b39 100644 --- a/src/components/layout/Default.js +++ b/src/components/layout/Default.js @@ -34,6 +34,8 @@ import IsUnlocked from 'components/IsUnlocked' import SideBar from 'components/MainSideBar' import TopBar from 'components/TopBar' import SyncBackground from 'components/SyncBackground' +import DebugUpdater from 'components/Updater/DebugUpdater' + import SyncContinuouslyPendingOperations from '../SyncContinouslyPendingOperations' import HSMStatusBanner from '../HSMStatusBanner' @@ -41,7 +43,7 @@ const Main = styled(GrowScroll).attrs({ px: 6, })` outline: none; - padding-top: ${p => p.theme.sizes.topBarHeight + p.theme.space[7]}px; + padding-top: ${p => p.theme.sizes.topBarHeight + p.theme.space[4]}px; ` type Props = { @@ -98,6 +100,8 @@ class Default extends Component { ))} + {process.env.DEBUG_UPDATE && } + diff --git a/src/components/modals/AccountSettingRenderBody.js b/src/components/modals/AccountSettingRenderBody.js index eb1e1d0d..49991bcc 100644 --- a/src/components/modals/AccountSettingRenderBody.js +++ b/src/components/modals/AccountSettingRenderBody.js @@ -1,13 +1,13 @@ // @flow -import React, { PureComponent } from 'react' +import React, { PureComponent, Fragment } from 'react' import styled from 'styled-components' import { connect } from 'react-redux' import { compose } from 'redux' import get from 'lodash/get' import { translate } from 'react-i18next' -import type { Account, Unit, Currency } from '@ledgerhq/live-common/lib/types' +import type { Account, Unit, CryptoCurrency } from '@ledgerhq/live-common/lib/types' import type { T } from 'types/common' import { MODAL_SETTINGS_ACCOUNT, MAX_ACCOUNT_NAME_SIZE } from 'config/constants' import { validateNameEdition } from '@ledgerhq/live-common/lib/account' @@ -23,18 +23,14 @@ import TrackPage from 'analytics/TrackPage' import Spoiler from 'components/base/Spoiler' import CryptoCurrencyIcon from 'components/CryptoCurrencyIcon' import Box from 'components/base/Box' +import Space from 'components/base/Space' import Button from 'components/base/Button' import Input from 'components/base/Input' import Select from 'components/base/Select' import SyncAgo from 'components/SyncAgo' -import { - ModalBody, - ModalTitle, - ModalFooter, - ModalContent, - ConfirmModal, -} from 'components/base/Modal' +import ConfirmModal from 'components/base/Modal/ConfirmModal' +import ModalBody from 'components/base/Modal/ModalBody' type State = { accountName: ?string, @@ -74,7 +70,7 @@ const defaultState = { isRemoveAccountModalOpen: false, } -class HelperComp extends PureComponent { +class AccountSettingRenderBody extends PureComponent { state = { ...defaultState, } @@ -84,7 +80,6 @@ class HelperComp extends PureComponent { } getAccount(data: Object): Account { - // FIXME this should be a selector const { accountName } = this.state const account = get(data, 'account', {}) @@ -129,10 +124,9 @@ class HelperComp extends PureComponent { }) handleSubmit = (account: Account, onClose: () => void) => ( - e: SyntheticEvent, + e: SyntheticEvent, ) => { e.preventDefault() - const { updateAccount, setDataModal } = this.props const { accountName, accountUnit, endpointConfig, endpointConfigError } = this.state @@ -194,10 +188,10 @@ class HelperComp extends PureComponent { endpointConfigError, } = this.state const { t, onClose, data } = this.props + if (!data) return null const account = this.getAccount(data) const bridge = getBridgeForCurrency(account.currency) - const usefulData = { xpub: account.xpub || undefined, index: account.index, @@ -206,12 +200,15 @@ class HelperComp extends PureComponent { blockHeight: account.blockHeight, } + const onSubmit = this.handleSubmit(account, onClose) + return ( - -
- - {t('account.settings.title')} - + ( + + {t('account.settings.accountName.title')} @@ -224,6 +221,7 @@ class HelperComp extends PureComponent { value={account.name} maxLength={MAX_ACCOUNT_NAME_SIZE} onChange={this.handleChangeName} + onEnter={onSubmit} onFocus={e => this.handleFocus(e, 'accountName')} error={accountNameError} /> @@ -268,8 +266,7 @@ class HelperComp extends PureComponent { ) : null} -