diff --git a/public/locales/en/notify.json b/public/locales/en/notify.json index e6ced4649..c35e7606b 100644 --- a/public/locales/en/notify.json +++ b/public/locales/en/notify.json @@ -1,6 +1,9 @@ { "ipfsApiRequestFailed": "Could not connect. Please check if your daemon is running.", "windowIpfsRequestFailed": "IPFS request failed. Please check your IPFS Companion settings.", + "ipfsInvalidApiAddress": "The provided IPFS API address is invalid.", + "ipfsConnectSuccess": "Successfully connected to the IPFS API address", + "ipfsConnectFail": "Unable to connect to the provided IPFS API address", "ipfsIsBack": "Normal IPFS service has resumed. Enjoy!", "folderExists": "An item with that name already exists. Please choose another.", "filesFetchFailed": "Failed to get those files. Please check the path and try again.", diff --git a/src/bundles/ipfs-provider.js b/src/bundles/ipfs-provider.js index 2745d6aa7..306a956f5 100644 --- a/src/bundles/ipfs-provider.js +++ b/src/bundles/ipfs-provider.js @@ -19,6 +19,8 @@ import { perform } from './task' * @property {boolean} failed * @property {boolean} ready * @property {boolean} invalidAddress + * @property {boolean} pendingFirstConnection + * * * @typedef {import('./task').Perform<'IPFS_INIT', Error, InitResult, void>} Init * @typedef {Object} Stopped @@ -34,19 +36,37 @@ import { perform } from './task' * @typedef {Object} Dismiss * @property {'IPFS_API_ADDRESS_INVALID_DISMISS'} type * + * @typedef {Object} ConnectSuccess + * @property {'IPFS_CONNECT_SUCCEED'} type + * + * @typedef {Object} ConnectFail + * @property {'IPFS_CONNECT_FAILED'} type + * + * @typedef {Object} DismissError + * @property {'NOTIFY_DISMISSED'} type + * + * @typedef {Object} PendingFirstConnection + * @property {'IPFS_API_ADDRESS_PENDING_FIRST_CONNECTION'} type + * @property {boolean} pending + * * @typedef {Object} InitResult * @property {ProviderName} provider * @property {IPFSService} ipfs * @property {string} [apiAddress] - * @typedef {Init|Stopped|AddressUpdated|AddressInvalid|Dismiss} Message + * @typedef {Init|Stopped|AddressUpdated|AddressInvalid|Dismiss|PendingFirstConnection|ConnectFail|ConnectSuccess|DismissError} Message */ export const ACTIONS = Enum.from([ 'IPFS_INIT', 'IPFS_STOPPED', 'IPFS_API_ADDRESS_UPDATED', + 'IPFS_API_ADDRESS_PENDING_FIRST_CONNECTION', 'IPFS_API_ADDRESS_INVALID', - 'IPFS_API_ADDRESS_INVALID_DISMISS' + 'IPFS_API_ADDRESS_INVALID_DISMISS', + // Notifier actions + 'IPFS_CONNECT_FAILED', + 'IPFS_CONNECT_SUCCEED', + 'NOTIFY_DISMISSED', ]) /** @@ -99,6 +119,16 @@ const update = (state, message) => { case ACTIONS.IPFS_API_ADDRESS_INVALID_DISMISS: { return { ...state, invalidAddress: true } } + case ACTIONS.IPFS_API_ADDRESS_PENDING_FIRST_CONNECTION: { + const { pending } = message + return { ...state, pendingFirstConnection: pending } + } + case ACTIONS.IPFS_CONNECT_SUCCEED: { + return { ...state, failed: false } + } + case ACTIONS.IPFS_CONNECT_FAILED: { + return { ...state, failed: true } + } default: { return state } @@ -114,7 +144,8 @@ const init = () => { provider: null, failed: false, ready: false, - invalidAddress: false + invalidAddress: false, + pendingFirstConnection: false } } @@ -126,6 +157,14 @@ const readAPIAddressSetting = () => { return setting == null ? null : asAPIOptions(setting) } +/** + * @param {string|object} value + * @returns {boolean} + */ +export const checkValidAPIAddress = (value) => { + return asAPIOptions(value) != null +} + /** * @param {string|object} value * @returns {HTTPClientOptions|string|null} @@ -297,7 +336,11 @@ const selectors = { /** * @param {State} state */ - selectIpfsInitFailed: state => state.ipfs.failed + selectIpfsInitFailed: state => state.ipfs.failed, + /** + * @param {State} state + */ + selectIpfsPendingFirstConnection: state => state.ipfs.pendingFirstConnection, } /** @@ -308,16 +351,17 @@ const selectors = { const actions = { /** - * @returns {function(Context):Promise} + * @returns {function(Context):Promise} */ doTryInitIpfs: () => async ({ store }) => { - // We need to swallow error that `doInitIpfs` could produce othrewise it - // will bubble up and nothing will handle it. There is a code in - // `bundles/retry-init.js` that reacts to `IPFS_INIT` action and attempts - // to retry. + // There is a code in `bundles/retry-init.js` that reacts to `IPFS_INIT` + // action and attempts to retry. try { await store.doInitIpfs() + return true } catch (_) { + // Catches connection errors like timeouts + return false } }, /** @@ -353,7 +397,7 @@ const actions = { }) if (!result) { - throw Error('Could not connect to the IPFS API') + throw Error(`Could not connect to the IPFS API (${apiAddress})`) } else { return result } @@ -370,17 +414,46 @@ const actions = { /** * @param {string} address - * @returns {function(Context):Promise} + * @returns {function(Context):Promise} */ doUpdateIpfsApiAddress: (address) => async (context) => { const apiAddress = asAPIOptions(address) if (apiAddress == null) { - context.dispatch({ type: 'IPFS_API_ADDRESS_INVALID' }) + context.dispatch({ type: ACTIONS.IPFS_API_ADDRESS_INVALID }) + return false } else { await writeSetting('ipfsApi', apiAddress) - context.dispatch({ type: 'IPFS_API_ADDRESS_UPDATED', payload: apiAddress }) - - await context.store.doTryInitIpfs() + context.dispatch({ type: ACTIONS.IPFS_API_ADDRESS_UPDATED, payload: apiAddress }) + + // Sends action to indicate we're going to try to update the IPFS API address. + // There is logic to retry doTryInitIpfs in bundles/retry-init.js, so + // we're triggering the PENDING_FIRST_CONNECTION action here to avoid blocking + // the UI while we automatically retry. + context.dispatch({ + type: ACTIONS.IPFS_API_ADDRESS_PENDING_FIRST_CONNECTION, + pending: true + }) + context.dispatch({ + type: ACTIONS.IPFS_STOPPED + }) + context.dispatch({ + type: ACTIONS.NOTIFY_DISMISSED + }) + const succeeded = await context.store.doTryInitIpfs() + if (succeeded) { + context.dispatch({ + type: ACTIONS.IPFS_CONNECT_SUCCEED, + }) + } else { + context.dispatch({ + type: ACTIONS.IPFS_CONNECT_FAILED, + }) + } + context.dispatch({ + type: ACTIONS.IPFS_API_ADDRESS_PENDING_FIRST_CONNECTION, + pending: false + }) + return succeeded } }, diff --git a/src/bundles/notify.js b/src/bundles/notify.js index 24c650a05..cbe13ae08 100644 --- a/src/bundles/notify.js +++ b/src/bundles/notify.js @@ -70,6 +70,31 @@ const notify = { } } + if (action.type === 'IPFS_CONNECT_FAILED') { + return { + ...state, + show: true, + error: true, + eventId: action.type + } + } + if (action.type === 'IPFS_CONNECT_SUCCEED') { + return { + ...state, + show: true, + error: false, + eventId: action.type + } + } + if (action.type === 'IPFS_API_ADDRESS_INVALID') { + return { + ...state, + show: true, + error: true, + eventId: action.type + } + } + return state }, @@ -84,6 +109,15 @@ const notify = { if (eventId === 'STATS_FETCH_FAILED') { return provider === 'window.ipfs' ? 'windowIpfsRequestFailed' : 'ipfsApiRequestFailed' } + if (eventId === 'IPFS_CONNECT_FAILED') { + return 'ipfsConnectFail' + } + if (eventId === 'IPFS_CONNECT_SUCCEED') { + return 'ipfsConnectSuccess' + } + if (eventId === 'IPFS_API_ADDRESS_INVALID') { + return 'ipfsInvalidApiAddress' + } if (eventId === 'FILES_EVENT_FAILED') { const type = code ? code.replace(/^(ERR_)/, '') : '' diff --git a/src/components/api-address-form/ApiAddressForm.js b/src/components/api-address-form/ApiAddressForm.js index defdf0de6..9029af6f6 100644 --- a/src/components/api-address-form/ApiAddressForm.js +++ b/src/components/api-address-form/ApiAddressForm.js @@ -1,10 +1,26 @@ -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import { connect } from 'redux-bundler-react' import { withTranslation } from 'react-i18next' import Button from '../button/Button' +import { checkValidAPIAddress } from '../../bundles/ipfs-provider' -const ApiAddressForm = ({ t, doUpdateIpfsApiAddress, ipfsApiAddress }) => { +const ApiAddressForm = ({ t, doUpdateIpfsApiAddress, ipfsApiAddress, ipfsInitFailed }) => { const [value, setValue] = useState(asAPIString(ipfsApiAddress)) + const initialIsValidApiAddress = !checkValidAPIAddress(value) + const [showFailState, setShowFailState] = useState(initialIsValidApiAddress || ipfsInitFailed) + const [isValidApiAddress, setIsValidApiAddress] = useState(initialIsValidApiAddress) + + // Updates the border of the input to indicate validity + useEffect(() => { + setShowFailState(ipfsInitFailed) + }, [isValidApiAddress, ipfsInitFailed]) + + // Updates the border of the input to indicate validity + useEffect(() => { + const isValid = checkValidAPIAddress(value) + setIsValidApiAddress(isValid) + setShowFailState(!isValid) + }, [value]) const onChange = (event) => setValue(event.target.value) @@ -25,13 +41,13 @@ const ApiAddressForm = ({ t, doUpdateIpfsApiAddress, ipfsApiAddress }) => { id='api-address' aria-label={t('apiAddressForm.apiLabel')} type='text' - className='w-100 lh-copy monospace f5 pl1 pv1 mb2 charcoal input-reset ba b--black-20 br1 focus-outline' + className={`w-100 lh-copy monospace f5 pl1 pv1 mb2 charcoal input-reset ba b--black-20 br1 ${showFailState ? 'focus-outline-red b--red-muted' : 'focus-outline-green b--green-muted'}`} onChange={onChange} onKeyPress={onKeyPress} value={value} />
- +
) @@ -49,5 +65,6 @@ const asAPIString = (value) => { export default connect( 'doUpdateIpfsApiAddress', 'selectIpfsApiAddress', + 'selectIpfsInitFailed', withTranslation('app')(ApiAddressForm) ) diff --git a/src/settings/SettingsPage.js b/src/settings/SettingsPage.js index 61610de4e..af0bd014c 100644 --- a/src/settings/SettingsPage.js +++ b/src/settings/SettingsPage.js @@ -19,13 +19,14 @@ import Experiments from '../components/experiments/ExperimentsPanel' import Title from './Title' import CliTutorMode from '../components/cli-tutor-mode/CliTutorMode' import Checkbox from '../components/checkbox/Checkbox' +import ComponentLoader from '../loader/ComponentLoader.js' import StrokeCode from '../icons/StrokeCode' import { cliCmdKeys, cliCommandList } from '../bundles/files/consts' const PAUSE_AFTER_SAVE_MS = 3000 export const SettingsPage = ({ - t, tReady, isIpfsConnected, + t, tReady, isIpfsConnected, ipfsPendingFirstConnection, isConfigBlocked, isLoading, isSaving, hasSaveFailed, hasSaveSucceded, hasErrors, hasLocalChanges, hasExternalChanges, config, onChange, onReset, onSave, editorKey, analyticsEnabled, doToggleAnalytics, @@ -35,6 +36,17 @@ export const SettingsPage = ({ {t('title')} | IPFS + + {/* Enable a full screen loader after updating to a new IPFS API address. + * Will not show on consequent retries after a failure. + */} + { ipfsPendingFirstConnection + ?
+ +
+ : null } +
@@ -278,7 +290,7 @@ export class SettingsPageContainer extends React.Component { const { t, tReady, isConfigBlocked, ipfsConnected, configIsLoading, configLastError, configIsSaving, configSaveLastSuccess, configSaveLastError, isIpfsDesktop, analyticsEnabled, doToggleAnalytics, toursEnabled, - handleJoyrideCallback, isCliTutorModeEnabled, doToggleCliTutorMode + handleJoyrideCallback, isCliTutorModeEnabled, doToggleCliTutorMode, ipfsPendingFirstConnection, } = this.props const { hasErrors, hasLocalChanges, hasExternalChanges, editableConfig, editorKey } = this.state const hasSaveSucceded = this.isRecent(configSaveLastSuccess) @@ -290,6 +302,7 @@ export class SettingsPageContainer extends React.Component { t={t} tReady={tReady} isIpfsConnected={ipfsConnected} + ipfsPendingFirstConnection={ipfsPendingFirstConnection} isConfigBlocked={isConfigBlocked} isLoading={isLoading} isSaving={configIsSaving} @@ -321,6 +334,7 @@ export const TranslatedSettingsPage = withTranslation('settings')(SettingsPageCo export default connect( 'selectConfig', 'selectIpfsConnected', + 'selectIpfsPendingFirstConnection', 'selectIsConfigBlocked', 'selectConfigLastError', 'selectConfigIsLoading',