diff --git a/ui/component/app/index.js b/ui/component/app/index.js index d3db0fe1e..8bcef8c81 100644 --- a/ui/component/app/index.js +++ b/ui/component/app/index.js @@ -1,6 +1,7 @@ import { hot } from 'react-hot-loader/root'; import { connect } from 'react-redux'; -import { selectGetSyncErrorMessage, selectUploadCount } from 'lbryinc'; +import { selectUploadCount } from 'lbryinc'; +import { selectGetSyncErrorMessage } from 'redux/selectors/sync'; import { doFetchAccessToken, doUserSetReferrer } from 'redux/actions/user'; import { selectUser, selectAccessToken, selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectUnclaimedRewards } from 'redux/selectors/rewards'; diff --git a/ui/component/header/index.js b/ui/component/header/index.js index 1e8c8a3ec..9a2864fa0 100644 --- a/ui/component/header/index.js +++ b/ui/component/header/index.js @@ -1,7 +1,7 @@ import * as MODALS from 'constants/modal_types'; import { connect } from 'react-redux'; import { selectBalance, formatCredits, selectMyChannelClaims, SETTINGS } from 'lbry-redux'; -import { selectGetSyncErrorMessage } from 'lbryinc'; +import { selectGetSyncErrorMessage } from 'redux/selectors/sync'; import { selectUserVerifiedEmail, selectUserEmail, selectEmailToVerify, selectUser } from 'redux/selectors/user'; import { doClearEmailEntry, doClearPasswordEntry } from 'redux/actions/user'; import { doSetClientSetting } from 'redux/actions/settings'; diff --git a/ui/component/syncEnableFlow/index.js b/ui/component/syncEnableFlow/index.js index f4a97bf87..2a083f653 100644 --- a/ui/component/syncEnableFlow/index.js +++ b/ui/component/syncEnableFlow/index.js @@ -6,9 +6,8 @@ import { selectHasSyncedWallet, selectGetSyncIsPending, selectHashChanged, - doCheckSync, - doGetSync, -} from 'lbryinc'; +} from 'redux/selectors/sync'; +import { doCheckSync, doGetSync } from 'redux/actions/sync'; import { makeSelectClientSetting } from 'redux/selectors/settings'; import { doSetWalletSyncPreference } from 'redux/actions/settings'; import SyncToggle from './view'; diff --git a/ui/component/syncPassword/index.js b/ui/component/syncPassword/index.js index eebc17e0c..c6e6cb08d 100644 --- a/ui/component/syncPassword/index.js +++ b/ui/component/syncPassword/index.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { selectGetSyncIsPending, selectSyncApplyPasswordError } from 'lbryinc'; +import { selectGetSyncIsPending, selectSyncApplyPasswordError } from 'redux/selectors/sync'; import { doGetSyncDesktop } from 'redux/actions/syncwrapper'; import { selectUserEmail } from 'redux/selectors/user'; import { doSetClientSetting } from 'redux/actions/settings'; diff --git a/ui/component/syncToggle/index.js b/ui/component/syncToggle/index.js index 1fff69336..151d7cd7a 100644 --- a/ui/component/syncToggle/index.js +++ b/ui/component/syncToggle/index.js @@ -1,7 +1,7 @@ import { SETTINGS } from 'lbry-redux'; import { connect } from 'react-redux'; import { selectUserVerifiedEmail } from 'redux/selectors/user'; -import { selectGetSyncErrorMessage } from 'lbryinc'; +import { selectGetSyncErrorMessage } from 'redux/selectors/sync'; import { makeSelectClientSetting } from 'redux/selectors/settings'; import { doSetWalletSyncPreference } from 'redux/actions/settings'; import { doOpenModal } from 'redux/actions/app'; diff --git a/ui/component/userSignUp/index.js b/ui/component/userSignUp/index.js index 010a4f4dd..ccc921fc1 100644 --- a/ui/component/userSignUp/index.js +++ b/ui/component/userSignUp/index.js @@ -1,6 +1,6 @@ import REWARD_TYPES from 'rewards'; import { connect } from 'react-redux'; -import { selectGetSyncIsPending, selectSyncHash } from 'lbryinc'; +import { selectGetSyncIsPending, selectSyncHash } from 'redux/selectors/sync'; import { doClaimRewardType } from 'redux/actions/rewards'; import { doSetClientSetting } from 'redux/actions/settings'; import { selectClaimedRewards, makeSelectIsRewardClaimPending } from 'redux/selectors/rewards'; diff --git a/ui/component/walletBalance/index.js b/ui/component/walletBalance/index.js index 0af1a9c66..2e302b513 100644 --- a/ui/component/walletBalance/index.js +++ b/ui/component/walletBalance/index.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import { selectBalance, selectClaimsBalance, selectSupportsBalance, selectTipsBalance } from 'lbry-redux'; import { doOpenModal } from 'redux/actions/app'; -import { selectSyncHash } from 'lbryinc'; +import { selectSyncHash } from 'redux/selectors/sync'; import { selectClaimedRewards } from 'redux/selectors/rewards'; import WalletBalance from './view'; diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js index d979861b6..9278991b9 100644 --- a/ui/constants/action_types.js +++ b/ui/constants/action_types.js @@ -283,6 +283,20 @@ export const TOGGLE_BLOCK_CHANNEL = 'TOGGLE_BLOCK_CHANNEL'; export const WS_CONNECT = 'WS_CONNECT'; export const WS_DISCONNECT = 'WS_DISCONNECT'; +// Cross-device Sync +export const GET_SYNC_STARTED = 'GET_SYNC_STARTED'; +export const GET_SYNC_COMPLETED = 'GET_SYNC_COMPLETED'; +export const GET_SYNC_FAILED = 'GET_SYNC_FAILED'; +export const SET_SYNC_STARTED = 'SET_SYNC_STARTED'; +export const SET_SYNC_FAILED = 'SET_SYNC_FAILED'; +export const SET_SYNC_COMPLETED = 'SET_SYNC_COMPLETED'; +export const SET_DEFAULT_ACCOUNT = 'SET_DEFAULT_ACCOUNT'; +export const SYNC_APPLY_STARTED = 'SYNC_APPLY_STARTED'; +export const SYNC_APPLY_COMPLETED = 'SYNC_APPLY_COMPLETED'; +export const SYNC_APPLY_FAILED = 'SYNC_APPLY_FAILED'; +export const SYNC_APPLY_BAD_PASSWORD = 'SYNC_APPLY_BAD_PASSWORD'; +export const SYNC_RESET = 'SYNC_RESET'; + export const REACTIONS_LIST_STARTED = 'REACTIONS_LIST_STARTED'; export const REACTIONS_LIST_FAILED = 'REACTIONS_LIST_FAILED'; export const REACTIONS_LIST_COMPLETED = 'REACTIONS_LIST_COMPLETED'; diff --git a/ui/reducers.js b/ui/reducers.js index 3339aad69..243f0e409 100644 --- a/ui/reducers.js +++ b/ui/reducers.js @@ -1,15 +1,7 @@ import { combineReducers } from 'redux'; import { connectRouter } from 'connected-react-router'; import { claimsReducer, fileInfoReducer, walletReducer, tagsReducer, publishReducer } from 'lbry-redux'; -import { - costInfoReducer, - blacklistReducer, - filteredReducer, - homepageReducer, - statsReducer, - syncReducer, - webReducer, -} from 'lbryinc'; +import { costInfoReducer, blacklistReducer, filteredReducer, homepageReducer, statsReducer, webReducer } from 'lbryinc'; import appReducer from 'redux/reducers/app'; import contentReducer from 'redux/reducers/content'; import settingsReducer from 'redux/reducers/settings'; @@ -21,6 +13,7 @@ import commentsReducer from 'redux/reducers/comments'; import blockedReducer from 'redux/reducers/blocked'; import searchReducer from 'redux/reducers/search'; import reactionsReducer from 'redux/reducers/reactions'; +import syncReducer from 'redux/reducers/sync'; export default history => combineReducers({ diff --git a/ui/redux/actions/sync.js b/ui/redux/actions/sync.js new file mode 100644 index 000000000..a816f1bb6 --- /dev/null +++ b/ui/redux/actions/sync.js @@ -0,0 +1,282 @@ +import * as ACTIONS from 'constants/action_types'; +import { Lbryio } from 'lbryinc'; +import { Lbry, doWalletEncrypt, doWalletDecrypt } from 'lbry-redux'; + +export function doSetDefaultAccount(success, failure) { + return dispatch => { + dispatch({ + type: ACTIONS.SET_DEFAULT_ACCOUNT, + }); + + Lbry.account_list() + .then(accountList => { + const { lbc_mainnet: accounts } = accountList; + let defaultId; + for (let i = 0; i < accounts.length; ++i) { + if (accounts[i].satoshis > 0) { + defaultId = accounts[i].id; + break; + } + } + + // In a case where there's no balance on either account + // assume the second (which is created after sync) as default + if (!defaultId && accounts.length > 1) { + defaultId = accounts[1].id; + } + + // Set the default account + if (defaultId) { + Lbry.account_set({ account_id: defaultId, default: true }) + .then(() => { + if (success) { + success(); + } + }) + .catch(err => { + if (failure) { + failure(err); + } + }); + } else if (failure) { + // no default account to set + failure('Could not set a default account'); // fail + } + }) + .catch(err => { + if (failure) { + failure(err); + } + }); + }; +} + +export function doSetSync(oldHash, newHash, data) { + return dispatch => { + dispatch({ + type: ACTIONS.SET_SYNC_STARTED, + }); + + return Lbryio.call('sync', 'set', { old_hash: oldHash, new_hash: newHash, data }, 'post') + .then(response => { + if (!response.hash) { + throw Error('No hash returned for sync/set.'); + } + + return dispatch({ + type: ACTIONS.SET_SYNC_COMPLETED, + data: { syncHash: response.hash }, + }); + }) + .catch(error => { + dispatch({ + type: ACTIONS.SET_SYNC_FAILED, + data: { error }, + }); + }); + }; +} + +export function doGetSync(passedPassword, callback) { + const password = passedPassword === null || passedPassword === undefined ? '' : passedPassword; + + function handleCallback(error, hasNewData) { + if (callback) { + if (typeof callback !== 'function') { + throw new Error('Second argument passed to "doGetSync" must be a function'); + } + + callback(error, hasNewData); + } + } + + return dispatch => { + dispatch({ + type: ACTIONS.GET_SYNC_STARTED, + }); + + const data = {}; + + Lbry.wallet_status() + .then(status => { + if (status.is_locked) { + return Lbry.wallet_unlock({ password }); + } + + // Wallet is already unlocked + return true; + }) + .then(isUnlocked => { + if (isUnlocked) { + return Lbry.sync_hash(); + } + data.unlockFailed = true; + throw new Error(); + }) + .then(hash => Lbryio.call('sync', 'get', { hash }, 'post')) + .then(response => { + const syncHash = response.hash; + data.syncHash = syncHash; + data.syncData = response.data; + data.changed = response.changed; + data.hasSyncedWallet = true; + + if (response.changed) { + return Lbry.sync_apply({ password, data: response.data, blocking: true }); + } + }) + .then(response => { + if (!response) { + dispatch({ type: ACTIONS.GET_SYNC_COMPLETED, data }); + handleCallback(null, data.changed); + return; + } + + const { hash: walletHash, data: walletData } = response; + + if (walletHash !== data.syncHash) { + // different local hash, need to synchronise + dispatch(doSetSync(data.syncHash, walletHash, walletData)); + } + + dispatch({ type: ACTIONS.GET_SYNC_COMPLETED, data }); + handleCallback(null, data.changed); + }) + .catch(syncAttemptError => { + if (data.unlockFailed) { + dispatch({ type: ACTIONS.GET_SYNC_FAILED, data: { error: syncAttemptError } }); + + if (password !== '') { + dispatch({ type: ACTIONS.SYNC_APPLY_BAD_PASSWORD }); + } + + handleCallback(syncAttemptError); + } else if (data.hasSyncedWallet) { + const error = (syncAttemptError && syncAttemptError.message) || 'Error getting synced wallet'; + dispatch({ + type: ACTIONS.GET_SYNC_FAILED, + data: { + error, + }, + }); + + // Temp solution until we have a bad password error code + // Don't fail on blank passwords so we don't show a "password error" message + // before users have ever entered a password + if (password !== '') { + dispatch({ type: ACTIONS.SYNC_APPLY_BAD_PASSWORD }); + } + + handleCallback(error); + } else { + // user doesn't have a synced wallet + dispatch({ + type: ACTIONS.GET_SYNC_COMPLETED, + data: { hasSyncedWallet: false, syncHash: null }, + }); + + // call sync_apply to get data to sync + // first time sync. use any string for old hash + Lbry.sync_apply({ password }) + .then(({ hash: walletHash, data: syncApplyData }) => { + dispatch(doSetSync('', walletHash, syncApplyData, password)); + handleCallback(); + }) + .catch(syncApplyError => { + handleCallback(syncApplyError); + }); + } + }); + }; +} + +export function doSyncApply(syncHash, syncData, password) { + return dispatch => { + dispatch({ + type: ACTIONS.SYNC_APPLY_STARTED, + }); + + Lbry.sync_apply({ password, data: syncData }) + .then(({ hash: walletHash, data: walletData }) => { + dispatch({ + type: ACTIONS.SYNC_APPLY_COMPLETED, + }); + + if (walletHash !== syncHash) { + // different local hash, need to synchronise + dispatch(doSetSync(syncHash, walletHash, walletData)); + } + }) + .catch(() => { + dispatch({ + type: ACTIONS.SYNC_APPLY_FAILED, + data: { + error: 'Invalid password specified. Please enter the password for your previously synchronised wallet.', + }, + }); + }); + }; +} + +export function doCheckSync() { + return dispatch => { + dispatch({ + type: ACTIONS.GET_SYNC_STARTED, + }); + + Lbry.sync_hash().then(hash => { + Lbryio.call('sync', 'get', { hash }, 'post') + .then(response => { + const data = { + hasSyncedWallet: true, + syncHash: response.hash, + syncData: response.data, + hashChanged: response.changed, + }; + dispatch({ type: ACTIONS.GET_SYNC_COMPLETED, data }); + }) + .catch(() => { + // user doesn't have a synced wallet + dispatch({ + type: ACTIONS.GET_SYNC_COMPLETED, + data: { hasSyncedWallet: false, syncHash: null }, + }); + }); + }); + }; +} + +export function doResetSync() { + return dispatch => + new Promise(resolve => { + dispatch({ type: ACTIONS.SYNC_RESET }); + resolve(); + }); +} + +export function doSyncEncryptAndDecrypt(oldPassword, newPassword, encrypt) { + return dispatch => { + const data = {}; + return Lbry.sync_hash() + .then(hash => Lbryio.call('sync', 'get', { hash }, 'post')) + .then(syncGetResponse => { + data.oldHash = syncGetResponse.hash; + + return Lbry.sync_apply({ password: oldPassword, data: syncGetResponse.data }); + }) + .then(() => { + if (encrypt) { + dispatch(doWalletEncrypt(newPassword)); + } else { + dispatch(doWalletDecrypt()); + } + }) + .then(() => Lbry.sync_apply({ password: newPassword })) + .then(syncApplyResponse => { + if (syncApplyResponse.hash !== data.oldHash) { + return dispatch(doSetSync(data.oldHash, syncApplyResponse.hash, syncApplyResponse.data)); + } + }) + .catch(console.error); // eslint-disable-line + }; +} diff --git a/ui/redux/actions/syncwrapper.js b/ui/redux/actions/syncwrapper.js index 6a6e4ce7f..92e7c9764 100644 --- a/ui/redux/actions/syncwrapper.js +++ b/ui/redux/actions/syncwrapper.js @@ -1,5 +1,6 @@ // @flow -import { doGetSync, selectGetSyncIsPending, selectSetSyncIsPending } from 'lbryinc'; +import { doGetSync } from 'redux/actions/sync'; +import { selectGetSyncIsPending, selectSetSyncIsPending } from 'redux/selectors/sync'; import { makeSelectClientSetting } from 'redux/selectors/settings'; import { getSavedPassword } from 'util/saved-passwords'; import { doAnalyticsTagSync, doHandleSyncComplete } from 'redux/actions/app'; diff --git a/ui/redux/reducers/sync.js b/ui/redux/reducers/sync.js new file mode 100644 index 000000000..2c02c5541 --- /dev/null +++ b/ui/redux/reducers/sync.js @@ -0,0 +1,89 @@ +import * as ACTIONS from 'constants/action_types'; + +const reducers = {}; +const defaultState = { + hasSyncedWallet: false, + syncHash: null, + syncData: null, + setSyncErrorMessage: null, + getSyncErrorMessage: null, + syncApplyErrorMessage: '', + syncApplyIsPending: false, + syncApplyPasswordError: false, + getSyncIsPending: false, + setSyncIsPending: false, + hashChanged: false, +}; + +reducers[ACTIONS.GET_SYNC_STARTED] = state => + Object.assign({}, state, { + getSyncIsPending: true, + getSyncErrorMessage: null, + }); + +reducers[ACTIONS.GET_SYNC_COMPLETED] = (state, action) => + Object.assign({}, state, { + syncHash: action.data.syncHash, + syncData: action.data.syncData, + hasSyncedWallet: action.data.hasSyncedWallet, + getSyncIsPending: false, + hashChanged: action.data.hashChanged, + }); + +reducers[ACTIONS.GET_SYNC_FAILED] = (state, action) => + Object.assign({}, state, { + getSyncIsPending: false, + getSyncErrorMessage: action.data.error, + }); + +reducers[ACTIONS.SET_SYNC_STARTED] = state => + Object.assign({}, state, { + setSyncIsPending: true, + setSyncErrorMessage: null, + }); + +reducers[ACTIONS.SET_SYNC_FAILED] = (state, action) => + Object.assign({}, state, { + setSyncIsPending: false, + setSyncErrorMessage: action.data.error, + }); + +reducers[ACTIONS.SET_SYNC_COMPLETED] = (state, action) => + Object.assign({}, state, { + setSyncIsPending: false, + setSyncErrorMessage: null, + hasSyncedWallet: true, // sync was successful, so the user has a synced wallet at this point + syncHash: action.data.syncHash, + }); + +reducers[ACTIONS.SYNC_APPLY_STARTED] = state => + Object.assign({}, state, { + syncApplyPasswordError: false, + syncApplyIsPending: true, + syncApplyErrorMessage: '', + }); + +reducers[ACTIONS.SYNC_APPLY_COMPLETED] = state => + Object.assign({}, state, { + syncApplyIsPending: false, + syncApplyErrorMessage: '', + }); + +reducers[ACTIONS.SYNC_APPLY_FAILED] = (state, action) => + Object.assign({}, state, { + syncApplyIsPending: false, + syncApplyErrorMessage: action.data.error, + }); + +reducers[ACTIONS.SYNC_APPLY_BAD_PASSWORD] = state => + Object.assign({}, state, { + syncApplyPasswordError: true, + }); + +reducers[ACTIONS.SYNC_RESET] = () => defaultState; + +export default function syncReducer(state = defaultState, action) { + const handler = reducers[action.type]; + if (handler) return handler(state, action); + return state; +} diff --git a/ui/redux/selectors/sync.js b/ui/redux/selectors/sync.js new file mode 100644 index 000000000..111f01472 --- /dev/null +++ b/ui/redux/selectors/sync.js @@ -0,0 +1,25 @@ +import { createSelector } from 'reselect'; + +const selectState = state => state.sync || {}; + +export const selectHasSyncedWallet = createSelector(selectState, state => state.hasSyncedWallet); + +export const selectSyncHash = createSelector(selectState, state => state.syncHash); + +export const selectSyncData = createSelector(selectState, state => state.syncData); + +export const selectSetSyncErrorMessage = createSelector(selectState, state => state.setSyncErrorMessage); + +export const selectGetSyncErrorMessage = createSelector(selectState, state => state.getSyncErrorMessage); + +export const selectGetSyncIsPending = createSelector(selectState, state => state.getSyncIsPending); + +export const selectSetSyncIsPending = createSelector(selectState, state => state.setSyncIsPending); + +export const selectHashChanged = createSelector(selectState, state => state.hashChanged); + +export const selectSyncApplyIsPending = createSelector(selectState, state => state.syncApplyIsPending); + +export const selectSyncApplyErrorMessage = createSelector(selectState, state => state.syncApplyErrorMessage); + +export const selectSyncApplyPasswordError = createSelector(selectState, state => state.syncApplyPasswordError);