diff --git a/src/components/AccountPage/index.js b/src/components/AccountPage/index.js index 4ea52c02..464fb3a4 100644 --- a/src/components/AccountPage/index.js +++ b/src/components/AccountPage/index.js @@ -129,11 +129,9 @@ class AccountPage extends PureComponent { )} t('app:account.settings.title')}> openModal(MODAL_SETTINGS_ACCOUNT, { account })}> - + + + diff --git a/src/components/DeviceBusyIndicator.js b/src/components/DeviceBusyIndicator.js new file mode 100644 index 00000000..b77523bc --- /dev/null +++ b/src/components/DeviceBusyIndicator.js @@ -0,0 +1,38 @@ +import React, { PureComponent } from 'react' +import styled from 'styled-components' + +const Indicator = styled.div` + opacity: ${p => (p.busy ? 0.2 : 0)}; + width: 6px; + height: 6px; + border-radius: 3px; + background-color: black; + position: fixed; + bottom: 4px; + right: 4px; + z-index: 999; +` + +// NB this is done like this to be extremely performant. we don't want redux for this.. +const perPaths = {} +const instances = [] +export const onSetDeviceBusy = (path, busy) => { + perPaths[path] = busy + instances.forEach(i => i.forceUpdate()) +} + +class DeviceBusyIndicator extends PureComponent<{}> { + componentDidMount() { + instances.push(this) + } + componentWillUnmount() { + const i = instances.indexOf(this) + instances.splice(i, 1) + } + render() { + const busy = Object.values(perPaths).reduce((busy, b) => busy || b, false) + return + } +} + +export default DeviceBusyIndicator diff --git a/src/components/LibcoreBusyIndicator.js b/src/components/LibcoreBusyIndicator.js new file mode 100644 index 00000000..251f955b --- /dev/null +++ b/src/components/LibcoreBusyIndicator.js @@ -0,0 +1,37 @@ +import React, { PureComponent } from 'react' +import styled from 'styled-components' + +const Indicator = styled.div` + opacity: ${p => (p.busy ? 0.2 : 0)}; + width: 6px; + height: 6px; + border-radius: 3px; + background-color: black; + position: fixed; + bottom: 4px; + right: 4px; + z-index: 999; +` + +// NB this is done like this to be extremely performant. we don't want redux for this.. +let busy = false +const instances = [] +export const onSetLibcoreBusy = b => { + busy = b + instances.forEach(i => i.forceUpdate()) +} + +class LibcoreBusyIndicator extends PureComponent<{}> { + componentDidMount() { + instances.push(this) + } + componentWillUnmount() { + const i = instances.indexOf(this) + instances.splice(i, 1) + } + render() { + return + } +} + +export default LibcoreBusyIndicator diff --git a/src/components/SelectAccount/index.js b/src/components/SelectAccount/index.js index 3c1fe001..7e683cd9 100644 --- a/src/components/SelectAccount/index.js +++ b/src/components/SelectAccount/index.js @@ -69,6 +69,9 @@ const RawSelectAccount = ({ accounts, onChange, value, t, ...props }: Props) => renderValue={renderOption} renderOption={renderOption} placeholder={t('app:common.selectAccount')} + noOptionsMessage={({ inputValue }) => + t('app:common.selectAccountNoOption', { accountName: inputValue }) + } onChange={onChange} /> ) diff --git a/src/components/SelectCurrency/index.js b/src/components/SelectCurrency/index.js index 3d23dd91..6c7d9a54 100644 --- a/src/components/SelectCurrency/index.js +++ b/src/components/SelectCurrency/index.js @@ -40,6 +40,9 @@ const SelectCurrency = ({ onChange, value, t, placeholder, currencies, ...props renderValue={renderOption} options={options} placeholder={placeholder || t('app:common.selectCurrency')} + noOptionsMessage={({ inputValue }: { inputValue: string }) => + t('app:common.selectCurrencyNoOption', { currencyName: inputValue }) + } onChange={item => onChange(item ? item.currency : null)} {...props} /> diff --git a/src/components/SelectExchange.js b/src/components/SelectExchange.js index 168d509e..93c76db9 100644 --- a/src/components/SelectExchange.js +++ b/src/components/SelectExchange.js @@ -93,6 +93,10 @@ class SelectExchange extends Component< options={options} onChange={onChange} isLoading={options.length === 0} + placeholder={t('app:common.selectExchange')} + noOptionsMessage={({ inputValue }) => + t('app:common.selectExchangeNoOption', { exchangeName: inputValue }) + } {...props} /> ) diff --git a/src/components/SettingsPage/sections/Display.js b/src/components/SettingsPage/sections/Display.js index 1b3bd457..0a19b62a 100644 --- a/src/components/SettingsPage/sections/Display.js +++ b/src/components/SettingsPage/sections/Display.js @@ -169,7 +169,7 @@ class TabProfile extends PureComponent { to={counterValueCurrency} exchangeId={counterValueExchange} onChange={this.handleChangeExchange} - minWidth={150} + minWidth={200} /> diff --git a/src/components/TopBar/ActivityIndicator.js b/src/components/TopBar/ActivityIndicator.js index a2c9ff31..fb7b1e58 100644 --- a/src/components/TopBar/ActivityIndicator.js +++ b/src/components/TopBar/ActivityIndicator.js @@ -10,7 +10,6 @@ import type { T } from 'types/common' import type { AsyncState } from 'reducers/bridgeSync' import { globalSyncStateSelector } from 'reducers/bridgeSync' -import { hasAccountsSelector } from 'reducers/accounts' import { BridgeSyncConsumer } from 'bridge/BridgeSyncContext' import CounterValues from 'helpers/countervalues' @@ -23,7 +22,6 @@ import ItemContainer from './ItemContainer' const mapStateToProps = createStructuredSelector({ globalSyncState: globalSyncStateSelector, - hasAccounts: hasAccountsSelector, }) type Props = { @@ -128,37 +126,28 @@ class ActivityIndicatorInner extends PureComponent { } } -const ActivityIndicator = ({ - globalSyncState, - hasAccounts, - t, -}: { - globalSyncState: AsyncState, - hasAccounts: boolean, - t: T, -}) => - !hasAccounts ? null : ( - - {setSyncBehavior => ( - - {cvPolling => { - const isPending = cvPolling.pending || globalSyncState.pending - const isError = cvPolling.error || globalSyncState.error - return ( - - ) - }} - - )} - - ) +const ActivityIndicator = ({ globalSyncState, t }: { globalSyncState: AsyncState, t: T }) => ( + + {setSyncBehavior => ( + + {cvPolling => { + const isPending = cvPolling.pending || globalSyncState.pending + const isError = cvPolling.error || globalSyncState.error + return ( + + ) + }} + + )} + +) export default compose( translate(), diff --git a/src/components/TopBar/index.js b/src/components/TopBar/index.js index 11b4bd1c..13d88557 100644 --- a/src/components/TopBar/index.js +++ b/src/components/TopBar/index.js @@ -12,6 +12,7 @@ import type { T } from 'types/common' import { lock } from 'reducers/application' import { hasPassword } from 'reducers/settings' +import { hasAccountsSelector } from 'reducers/accounts' import { openModal } from 'reducers/modals' import IconLock from 'icons/Lock' @@ -54,6 +55,7 @@ const Bar = styled.div` const mapStateToProps = state => ({ hasPassword: hasPassword(state), + hasAccounts: hasAccountsSelector(state), }) const mapDispatchToProps = { @@ -63,6 +65,7 @@ const mapDispatchToProps = { type Props = { hasPassword: boolean, + hasAccounts: boolean, history: RouterHistory, location: Location, lock: Function, @@ -91,17 +94,21 @@ class TopBar extends PureComponent { } } render() { - const { hasPassword, t } = this.props + const { hasPassword, hasAccounts, t } = this.props return ( - - - - + {hasAccounts && ( + + + + + + + )} t('app:settings.title')}> diff --git a/src/components/base/Button/index.js b/src/components/base/Button/index.js index dc7f2515..8953eb31 100644 --- a/src/components/base/Button/index.js +++ b/src/components/base/Button/index.js @@ -16,8 +16,12 @@ type Style = any // FIXME const buttonStyles: { [_: string]: Style } = { default: { default: noop, - active: noop, - hover: noop, + active: p => ` + background: ${rgba(p.theme.colors.fog, 0.3)}; + `, + hover: p => ` + background: ${rgba(p.theme.colors.fog, 0.2)}; + `, focus: () => ` box-shadow: ${focusedShadowStyle}; `, diff --git a/src/components/base/Modal/ModalTitle.js b/src/components/base/Modal/ModalTitle.js index c4f4c7ed..cc812853 100644 --- a/src/components/base/Modal/ModalTitle.js +++ b/src/components/base/Modal/ModalTitle.js @@ -30,6 +30,7 @@ const Back = styled(Box).attrs({ })` cursor: pointer; position: absolute; + line-height: 1; top: 0; left: 0; diff --git a/src/components/layout/Default.js b/src/components/layout/Default.js index e7286fa0..c9a16c8c 100644 --- a/src/components/layout/Default.js +++ b/src/components/layout/Default.js @@ -17,6 +17,8 @@ import DashboardPage from 'components/DashboardPage' import ManagerPage from 'components/ManagerPage' import ExchangePage from 'components/ExchangePage' import SettingsPage from 'components/SettingsPage' +import LibcoreBusyIndicator from 'components/LibcoreBusyIndicator' +import DeviceBusyIndicator from 'components/DeviceBusyIndicator' import AppRegionDrag from 'components/AppRegionDrag' import IsUnlocked from 'components/IsUnlocked' @@ -96,6 +98,9 @@ class Default extends Component { + + + ) diff --git a/src/components/modals/AddAccounts/steps/03-step-import.js b/src/components/modals/AddAccounts/steps/03-step-import.js index 4573e6a8..ac05fbb1 100644 --- a/src/components/modals/AddAccounts/steps/03-step-import.js +++ b/src/components/modals/AddAccounts/steps/03-step-import.js @@ -11,7 +11,7 @@ import Box from 'components/base/Box' import CurrencyBadge from 'components/base/CurrencyBadge' import Button from 'components/base/Button' import AccountsList from 'components/base/AccountsList' -import IconExchange from 'icons/Exchange' +import IconExclamationCircleThin from 'icons/ExclamationCircleThin' import type { StepProps } from '../index' @@ -20,14 +20,30 @@ class StepImport extends PureComponent { this.startScanAccountsDevice() } + componentDidUpdate(prevProps: StepProps) { + // handle case when we click on stop sync + if (prevProps.scanStatus !== 'finished' && this.props.scanStatus === 'finished') { + this.unsub() + } + + // handle case when we click on retry sync + if (prevProps.scanStatus !== 'scanning' && this.props.scanStatus === 'scanning') { + this.startScanAccountsDevice() + } + } + componentWillUnmount() { + this.unsub() + } + + scanSubscription = null + + unsub = () => { if (this.scanSubscription) { this.scanSubscription.unsubscribe() } } - scanSubscription = null - translateName(account: Account) { const { t } = this.props let { name } = account @@ -45,6 +61,7 @@ class StepImport extends PureComponent { } startScanAccountsDevice() { + this.unsub() const { currency, currentDevice, setState } = this.props try { invariant(currency, 'No currency to scan') @@ -82,10 +99,7 @@ class StepImport extends PureComponent { } handleRetry = () => { - if (this.scanSubscription) { - this.scanSubscription.unsubscribe() - this.scanSubscription = null - } + this.unsub() this.handleResetState() this.startScanAccountsDevice() } @@ -131,6 +145,17 @@ class StepImport extends PureComponent { handleUnselectAll = () => this.props.setState({ checkedAccountsIds: [] }) + renderError() { + const { err, t } = this.props + invariant(err, 'Trying to render inexisting error') + return ( + + + {t('app:addAccounts.somethingWentWrong')} + + ) + } + render() { const { scanStatus, @@ -142,6 +167,12 @@ class StepImport extends PureComponent { t, } = this.props + if (err) { + return this.renderError() + } + + const currencyName = currency ? currency.name : '' + const importableAccounts = scannedAccounts.filter(acc => { if (acc.operations.length <= 0) { return false @@ -160,9 +191,8 @@ class StepImport extends PureComponent { count: importableAccounts.length, }) - const importableAccountsEmpty = t('app:addAccounts.noAccountToImport', { - currencyName: currency ? ` ${currency.name}}` : '', - }) + const importableAccountsEmpty = t('app:addAccounts.noAccountToImport', { currencyName }) + const hasAlreadyEmptyAccount = scannedAccounts.some(a => a.operations.length === 0) return ( @@ -180,7 +210,11 @@ class StepImport extends PureComponent { /> { /> - {err && ( - - {err.message} - - - )} + {err && {err.message}} ) } @@ -208,6 +232,7 @@ class StepImport extends PureComponent { export default StepImport export const StepImportFooter = ({ + setState, scanStatus, onClickAdd, onCloseModal, @@ -250,7 +275,21 @@ export const StepImportFooter = ({ return ( {currency && } - + )} + {scanStatus === 'scanning' && ( + + )} + diff --git a/src/config/constants.js b/src/config/constants.js index 5e5f8b09..40400588 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -65,6 +65,7 @@ export const SKIP_GENUINE = boolFromEnv('SKIP_GENUINE') export const SKIP_ONBOARDING = boolFromEnv('SKIP_ONBOARDING') export const SHOW_LEGACY_NEW_ACCOUNT = boolFromEnv('SHOW_LEGACY_NEW_ACCOUNT') export const HIGHLIGHT_I18N = boolFromEnv('HIGHLIGHT_I18N') +export const DISABLE_ACTIVITY_INDICATORS = boolFromEnv('DISABLE_ACTIVITY_INDICATORS') // Other constants diff --git a/src/helpers/deviceAccess.js b/src/helpers/deviceAccess.js index 34aa3499..c380fe0c 100644 --- a/src/helpers/deviceAccess.js +++ b/src/helpers/deviceAccess.js @@ -18,7 +18,7 @@ export const withDevice: WithDevice = devicePath => { semaphorePerDevice[devicePath] || (semaphorePerDevice[devicePath] = createSemaphore(1)) return job => - takeSemaphorePromise(sem, async () => { + takeSemaphorePromise(sem, devicePath, async () => { const t = await retry(() => TransportNodeHid.open(devicePath), { maxRetry: 1 }) if (DEBUG_DEVICE) t.setDebugMode(true) @@ -32,17 +32,32 @@ export const withDevice: WithDevice = devicePath => { }) } -function takeSemaphorePromise(sem, f: () => Promise): Promise { +function takeSemaphorePromise(sem, devicePath, f: () => Promise): Promise { return new Promise((resolve, reject) => { sem.take(() => { + process.send({ + type: 'setDeviceBusy', + busy: true, + devicePath, + }) f().then( r => { sem.leave() resolve(r) + process.send({ + type: 'setDeviceBusy', + busy: false, + devicePath, + }) }, e => { sem.leave() reject(e) + process.send({ + type: 'setDeviceBusy', + busy: false, + devicePath, + }) }, ) }) diff --git a/src/helpers/withLibcore.js b/src/helpers/withLibcore.js index e2d42054..f1fe3fd4 100644 --- a/src/helpers/withLibcore.js +++ b/src/helpers/withLibcore.js @@ -3,8 +3,24 @@ // TODO: `core` should be typed type Job = Object => Promise -export default function withLibcore(job: Job): Promise { +let counter = 0 +export default async function withLibcore(job: Job): Promise { const core = require('./init-libcore').default core.getPoolInstance() - return job(core) + try { + if (counter++ === 0) { + process.send({ + type: 'setLibcoreBusy', + busy: true, + }) + } + return job(core) + } finally { + if (--counter === 0) { + process.send({ + type: 'setLibcoreBusy', + busy: false, + }) + } + } } diff --git a/src/main/bridge.js b/src/main/bridge.js index 2b71fcd1..79323d2e 100644 --- a/src/main/bridge.js +++ b/src/main/bridge.js @@ -97,16 +97,19 @@ ipcMainListenReceiveCommands({ }) function handleGlobalInternalMessage(payload) { - if (payload.type === 'executeHttpQueryOnRenderer') { - const win = getMainWindow && getMainWindow() - if (!win) { - logger.warn("can't executeHttpQueryOnRenderer because no renderer") - return + switch (payload.type) { + case 'setLibcoreBusy': + case 'setDeviceBusy': + case 'executeHttpQueryOnRenderer': { + const win = getMainWindow && getMainWindow() + if (!win) { + logger.warn(`can't ${payload.type} because no renderer`) + return + } + win.webContents.send(payload.type, payload) + break } - win.webContents.send('executeHttpQuery', { - id: payload.id, - networkArg: payload.networkArg, - }) + default: } } diff --git a/src/renderer/events.js b/src/renderer/events.js index bb106f27..63d266f2 100644 --- a/src/renderer/events.js +++ b/src/renderer/events.js @@ -15,7 +15,9 @@ import network from 'api/network' import { ipcRenderer } from 'electron' import debug from 'debug' -import { CHECK_UPDATE_DELAY } from 'config/constants' +import { CHECK_UPDATE_DELAY, DISABLE_ACTIVITY_INDICATORS } from 'config/constants' +import { onSetDeviceBusy } from 'components/DeviceBusyIndicator' +import { onSetLibcoreBusy } from 'components/LibcoreBusyIndicator' import { hasPassword } from 'reducers/settings' import { lock } from 'reducers/application' @@ -87,7 +89,7 @@ export default ({ store }: { store: Object }) => { } }) - ipcRenderer.on('executeHttpQuery', (event: any, { networkArg, id }) => { + ipcRenderer.on('executeHttpQueryOnRenderer', (event: any, { networkArg, id }) => { network(networkArg).then( result => { ipcRenderer.send('executeHttpQueryPayload', { type: 'success', id, result }) @@ -98,6 +100,16 @@ export default ({ store }: { store: Object }) => { ) }) + if (!DISABLE_ACTIVITY_INDICATORS) { + ipcRenderer.on('setLibcoreBusy', (event: any, { busy }) => { + onSetLibcoreBusy(busy) + }) + + ipcRenderer.on('setDeviceBusy', (event: any, { busy, devicePath }) => { + onSetDeviceBusy(devicePath, busy) + }) + } + if (__PROD__) { // TODO move this to "command" pattern const updaterHandlers = { diff --git a/static/i18n/en/app.yml b/static/i18n/en/app.yml index f2288758..606a568f 100644 --- a/static/i18n/en/app.yml +++ b/static/i18n/en/app.yml @@ -11,7 +11,11 @@ common: chooseWalletPlaceholder: Choose a wallet... currency: Currency selectAccount: Select an account + selectAccountNoOption: 'No account matching "{{accountName}}"' selectCurrency: Select a currency + selectCurrencyNoOption: 'No currency matching "{{currencyName}}"' + selectExchange: Select an exchange + selectExchangeNoOption: 'No exchange matching "{{exchangeName}}"' sortBy: Sort by search: Search save: Save @@ -23,6 +27,7 @@ common: next: Next back: Back retry: Retry + stop: Stop close: Close eastern: Eastern western: Western @@ -138,12 +143,13 @@ addAccounts: editName: Edit name newAccount: New account legacyAccount: '{{accountName}} (legacy)' - noAccountToImport: We didnt find any {{currencyName}}} account to import. + noAccountToImport: We didnt find any {{currencyName}} account to import. success: Great success! createNewAccount: title: Create new account noOperationOnLastAccount: You cannot create a new account because your last account has no operations - retrySync: Retry sync + noAccountToCreate: We didnt find any {{currencyName}} account to create. + somethingWentWrong: Something went wrong during synchronization. cta: create: 'Create account' import: 'Import account'