diff --git a/.eslintrc b/.eslintrc index a5c7283d..e22c8543 100644 --- a/.eslintrc +++ b/.eslintrc @@ -60,6 +60,7 @@ "react/jsx-no-literals": [1, {"noStrings": false}], "react/prefer-stateless-function": 0, "react/require-default-props": 0, + "react/no-multi-comp": 0, "react/sort-comp": [1, { order: [ 'static-methods', diff --git a/src/actions/settings.js b/src/actions/settings.js index 60251280..afd189d4 100644 --- a/src/actions/settings.js +++ b/src/actions/settings.js @@ -45,3 +45,8 @@ export const setExchangePairsAction: SetExchangePairs = pairs => ({ type: 'SETTINGS_SET_PAIRS', pairs, }) + +export const dismissBanner = (bannerId: string) => ({ + type: 'SETTINGS_DISMISS_BANNER', + payload: bannerId, +}) diff --git a/src/bridge/BridgeSyncContext.js b/src/bridge/BridgeSyncContext.js index 977ffdfb..7a4228c8 100644 --- a/src/bridge/BridgeSyncContext.js +++ b/src/bridge/BridgeSyncContext.js @@ -16,7 +16,9 @@ import { setAccountSyncState } from 'actions/bridgeSync' import { bridgeSyncSelector, syncStateLocalSelector } from 'reducers/bridgeSync' import type { BridgeSyncState } from 'reducers/bridgeSync' import { accountsSelector, isUpToDateSelector } from 'reducers/accounts' +import { currenciesStatusSelector, getIsCurrencyDown } from 'reducers/currenciesStatus' import { SYNC_MAX_CONCURRENT, SYNC_TIMEOUT } from 'config/constants' +import type { CurrencyStatus } from 'reducers/currenciesStatus' import { getBridgeForCurrency } from '.' type BridgeSyncProviderProps = { @@ -29,6 +31,7 @@ type BridgeSyncProviderOwnProps = BridgeSyncProviderProps & { isUpToDate: boolean, updateAccountWithUpdater: (string, (Account) => Account) => void, setAccountSyncState: (string, AsyncState) => *, + currenciesStatus: CurrencyStatus[], } type AsyncState = { @@ -48,6 +51,7 @@ export type Sync = (action: BehaviorAction) => void const BridgeSyncContext = React.createContext((_: BehaviorAction) => {}) const mapStateToProps = createStructuredSelector({ + currenciesStatus: currenciesStatusSelector, accounts: accountsSelector, bridgeSync: bridgeSyncSelector, isUpToDate: isUpToDateSelector, @@ -73,6 +77,11 @@ class Provider extends Component { return } + if (getIsCurrencyDown(this.props.currenciesStatus, account.currency)) { + next() + return + } + const bridge = getBridgeForCurrency(account.currency) this.props.setAccountSyncState(accountId, { pending: true, error: null }) diff --git a/src/components/CurrenciesStatusBanner.js b/src/components/CurrenciesStatusBanner.js new file mode 100644 index 00000000..df9af8f2 --- /dev/null +++ b/src/components/CurrenciesStatusBanner.js @@ -0,0 +1,181 @@ +// @flow + +import React, { PureComponent } from 'react' +import { compose } from 'redux' +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 { colors } from 'styles/theme' +import { openURL } from 'helpers/linking' +import { CHECK_CUR_STATUS_INTERVAL } from 'config/constants' +import IconCross from 'icons/Cross' +import IconTriangleWarning from 'icons/TriangleWarning' +import IconChevronRight from 'icons/ChevronRight' + +import { dismissedBannersSelector } from 'reducers/settings' +import { currenciesStatusSelector, fetchCurrenciesStatus } from 'reducers/currenciesStatus' +import { currenciesSelector } from 'reducers/accounts' +import { dismissBanner } from 'actions/settings' +import type { CurrencyStatus } from 'reducers/currenciesStatus' + +import Box from 'components/base/Box' + +const mapStateToProps = createStructuredSelector({ + dismissedBanners: dismissedBannersSelector, + accountsCurrencies: currenciesSelector, + currenciesStatus: currenciesStatusSelector, +}) + +const mapDispatchToProps = { + dismissBanner, + fetchCurrenciesStatus, +} + +const getItemKey = (item: CurrencyStatus) => `${item.id}_${item.nonce}` + +const CloseIconContainer = styled.div` + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 10px; + border-bottom-left-radius: 4px; + + opacity: 0.5; + &:hover { + opacity: 1; + } +` + +const CloseIcon = (props: *) => ( + + + +) + +type Props = { + accountsCurrencies: Currency[], + dismissedBanners: string[], + dismissBanner: string => void, + currenciesStatus: CurrencyStatus[], + fetchCurrenciesStatus: () => Promise, + t: *, +} + +class CurrenciesStatusBanner extends PureComponent { + componentDidMount() { + this.pollStatus() + } + + componentWillUnmount() { + this.unmounted = true + if (this.timeout) { + clearTimeout(this.timeout) + } + } + + unmounted = false + timeout: * + + pollStatus = async () => { + await this.props.fetchCurrenciesStatus() + if (this.unmounted) return + this.timeout = setTimeout(this.pollStatus, CHECK_CUR_STATUS_INTERVAL) + } + + dismiss = item => this.props.dismissBanner(getItemKey(item)) + + render() { + const { dismissedBanners, accountsCurrencies, currenciesStatus, t } = this.props + const filtered = currenciesStatus.filter( + item => + accountsCurrencies.find(cur => cur.id === item.id) && + dismissedBanners.indexOf(getItemKey(item)) === -1, + ) + if (!filtered.length) return null + return ( + + {filtered.map(r => )} + + ) + } +} + +class BannerItem extends PureComponent<{ + item: CurrencyStatus, + onItemDismiss: CurrencyStatus => void, + t: *, +}> { + onLinkClick = () => openURL(this.props.item.link) + dismiss = () => this.props.onItemDismiss(this.props.item) + render() { + const { item, t } = this.props + return ( + + + + + + {item.message} + + + {item.link && } + + ) + } +} + +const UnderlinedLink = styled.span` + border-bottom: 1px solid transparent; + &:hover { + border-bottom-color: white; + } +` + +const BannerItemLink = ({ t, onClick }: { t: *, onClick: void => * }) => ( + + + {t('common.learnMore')} + +) + +const styles = { + container: { + position: 'fixed', + left: 32, + bottom: 32, + }, + banner: { + background: colors.alertRed, + overflow: 'hidden', + borderRadius: 4, + fontSize: 13, + padding: 14, + color: 'white', + fontWeight: 'bold', + paddingRight: 50, + width: 350, + }, +} + +export default compose( + connect( + mapStateToProps, + mapDispatchToProps, + ), + translate(), +)(CurrenciesStatusBanner) diff --git a/src/components/TopBar/index.js b/src/components/TopBar/index.js index 3378772f..b18914c5 100644 --- a/src/components/TopBar/index.js +++ b/src/components/TopBar/index.js @@ -21,6 +21,7 @@ import IconSettings from 'icons/Settings' import Box from 'components/base/Box' import GlobalSearch from 'components/GlobalSearch' import Tooltip from 'components/base/Tooltip' +import CurrenciesStatusBanner from 'components/CurrenciesStatusBanner' import ActivityIndicator from './ActivityIndicator' import ItemContainer from './ItemContainer' @@ -101,6 +102,7 @@ class TopBar extends PureComponent { + {hasAccounts && ( diff --git a/src/config/constants.js b/src/config/constants.js index 06ad1b7d..ac43b517 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -32,6 +32,7 @@ export const MIN_HEIGHT = intFromEnv('LEDGER_MIN_HEIGHT', 700) export const CHECK_APP_INTERVAL_WHEN_INVALID = 600 export const CHECK_APP_INTERVAL_WHEN_VALID = 1200 export const CHECK_UPDATE_DELAY = 5000 +export const CHECK_CUR_STATUS_INTERVAL = intFromEnv('CHECK_CUR_STATUS_INTERVAL', 60 * 60 * 1000) export const DEVICE_INFOS_TIMEOUT = intFromEnv('DEVICE_INFOS_TIMEOUT', 5 * 1000) export const GENUINE_CACHE_DELAY = intFromEnv('GENUINE_CACHE_DELAY', 1000) export const GENUINE_TIMEOUT = intFromEnv('GENUINE_TIMEOUT', 120 * 1000) diff --git a/src/config/urls.js b/src/config/urls.js index c0ddc86b..8b66332f 100644 --- a/src/config/urls.js +++ b/src/config/urls.js @@ -36,4 +36,8 @@ export const urls = { errors: { CantOpenDevice: 'https://support.ledgerwallet.com/hc/en-us/articles/115005165269', }, + + // Currencies status + currenciesStatus: + 'https://s3-eu-west-1.amazonaws.com/ledger-ledgerlive-resources-prod/public_resources/currencies.json', } diff --git a/src/reducers/currenciesStatus.js b/src/reducers/currenciesStatus.js new file mode 100644 index 00000000..8c1ec8a4 --- /dev/null +++ b/src/reducers/currenciesStatus.js @@ -0,0 +1,58 @@ +// @flow + +import { handleActions, createAction } from 'redux-actions' +import type { Currency } from '@ledgerhq/live-common/lib/types' + +import network from 'api/network' +import { urls } from 'config/urls' +import logger from 'logger' + +import type { State } from './index' + +export type CurrencyStatus = { + id: string, // the currency id + status: 'KO' | 'OK', + message: string, + link: string, + nonce: number, +} + +export type CurrenciesStatusState = CurrencyStatus[] + +const state: CurrenciesStatusState = [] + +const handlers = { + CURRENCIES_STATUS_SET: ( + state: CurrenciesStatusState, + { payload }: { payload: CurrenciesStatusState }, + ) => payload, +} + +// Actions + +const setCurrenciesStatus = createAction('CURRENCIES_STATUS_SET') +export const fetchCurrenciesStatus = () => async (dispatch: *) => { + try { + const { data } = await network({ + method: 'GET', + url: process.env.LL_STATUS_ENDPOINT || urls.currenciesStatus, + }) + dispatch(setCurrenciesStatus(data)) + } catch (err) { + logger.error(err) + } +} + +// Selectors + +export const currenciesStatusSelector = (state: State) => state.currenciesStatus + +// It's not a *real* selector, but it's better than having this logic inside component +export const getIsCurrencyDown = (currenciesStatus: CurrenciesStatusState, currency: Currency) => { + const item = currenciesStatus.find(c => c.id === currency.id) + return !!item && item.status === 'KO' +} + +// Exporting reducer + +export default handleActions(handlers, state) diff --git a/src/reducers/index.js b/src/reducers/index.js index f2e2f76e..a84f2d3e 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -9,6 +9,7 @@ import type { CounterValuesState } from '@ledgerhq/live-common/lib/countervalues import CounterValues from 'helpers/countervalues' import accounts from './accounts' import application from './application' +import currenciesStatus from './currenciesStatus' import devices from './devices' import modals from './modals' import settings from './settings' @@ -24,11 +25,13 @@ import type { SettingsState } from './settings' import type { UpdateState } from './update' import type { OnboardingState } from './onboarding' import type { BridgeSyncState } from './bridgeSync' +import type { CurrenciesStatusState } from './currenciesStatus' export type State = { accounts: AccountsState, application: ApplicationState, countervalues: CounterValuesState, + currenciesStatus: CurrenciesStatusState, devices: DevicesState, modals: ModalsState, router: LocationShape, @@ -42,6 +45,7 @@ export default combineReducers({ accounts, application, countervalues: CounterValues.reducer, + currenciesStatus, devices, modals, router, diff --git a/src/reducers/settings.js b/src/reducers/settings.js index a62f81a8..4ef84c24 100644 --- a/src/reducers/settings.js +++ b/src/reducers/settings.js @@ -47,6 +47,7 @@ export type SettingsState = { shareAnalytics: boolean, sentryLogs: boolean, lastUsedVersion: string, + dismissedBanners: string[], } const defaultsForCurrency: CryptoCurrency => CurrencySettings = crypto => { @@ -73,6 +74,7 @@ const INITIAL_STATE: SettingsState = { shareAnalytics: true, sentryLogs: true, lastUsedVersion: __APP_VERSION__, + dismissedBanners: [], } function asCryptoCurrency(c: Currency): ?CryptoCurrency { @@ -126,6 +128,14 @@ const handlers: Object = { ...settings, loaded: true, }), + SETTINGS_DISMISS_BANNER: (state: SettingsState, { payload: bannerId }) => ({ + ...state, + dismissedBanners: [...state.dismissedBanners, bannerId], + }), + CLEAN_ACCOUNTS_CACHE: (state: SettingsState) => ({ + ...state, + dismissedBanners: [], + }), } // TODO refactor selectors to *Selector naming convention @@ -224,6 +234,8 @@ export const selectedTimeRangeSelector = (state: State) => state.settings.select export const hasCompletedOnboardingSelector = (state: State) => state.settings.hasCompletedOnboarding +export const dismissedBannersSelector = (state: State) => state.settings.dismissedBanners || [] + export const exportSettingsSelector = createSelector( counterValueCurrencySelector, counterValueExchangeSelector, diff --git a/static/i18n/en/app.json b/static/i18n/en/app.json index f6ddfe72..d6dc3f3b 100644 --- a/static/i18n/en/app.json +++ b/static/i18n/en/app.json @@ -11,6 +11,7 @@ "continue": "Continue", "learnMore": "Learn more", "help": "Help", + "dismiss": "Dismiss", "skipThisStep": "Skip this step", "needHelp": "Need help?", "areYouSure": "Are you sure?",