lbry-desktop/ui/redux/actions/user.js
infinite-persistence 9d830615fd
Fix user membership state for incognito.
Run the membership reducer even for incognito.

The GUI needs to differentiate between 'unfetched' (`undefined`) and 'no membership' (`''`), so we must call the action/reducer for the incognito case as well.
2022-05-30 19:03:21 +08:00

961 lines
26 KiB
JavaScript

import Lbry from 'lbry';
import { selectClaimForUri } from 'redux/selectors/claims';
import { doFetchChannelListMine } from 'redux/actions/claims';
import { isURIValid, normalizeURI } from 'util/lbryURI';
import { batchActions } from 'util/batch-actions';
import { getStripeEnvironment } from 'util/stripe';
import { ODYSEE_CHANNEL } from 'constants/channels';
import * as ACTIONS from 'constants/action_types';
import { doFetchGeoBlockedList } from 'redux/actions/blocked';
import { doClaimRewardType, doRewardList } from 'redux/actions/rewards';
import { selectEmailToVerify, selectPhoneToVerify, selectUserCountryCode, selectUser } from 'redux/selectors/user';
import { selectIsRewardApproved } from 'redux/selectors/rewards';
import { doToast } from 'redux/actions/notifications';
import rewards from 'rewards';
import { Lbryio } from 'lbryinc';
import { DOMAIN, LOCALE_API } from 'config';
import { getDefaultLanguage } from 'util/default-languages';
import { LocalStorage, LS } from 'util/storage';
export let sessionStorageAvailable = false;
const CHECK_INTERVAL = 200;
const AUTH_WAIT_TIMEOUT = 10000;
const stripeEnvironment = getStripeEnvironment();
export function doFetchInviteStatus(shouldCallRewardList = true) {
return (dispatch) => {
dispatch({
type: ACTIONS.USER_INVITE_STATUS_FETCH_STARTED,
});
Promise.all([Lbryio.call('user', 'invite_status'), Lbryio.call('user_referral_code', 'list')])
.then(([status, code]) => {
if (shouldCallRewardList) {
dispatch(doRewardList());
}
dispatch({
type: ACTIONS.USER_INVITE_STATUS_FETCH_SUCCESS,
data: {
invitesRemaining: status.invites_remaining ? status.invites_remaining : 0,
invitees: status.invitees,
referralLink: `${Lbryio.CONNECTION_STRING}user/refer?r=${code}`,
referralCode: code,
},
});
})
.catch((error) => {
dispatch({
type: ACTIONS.USER_INVITE_STATUS_FETCH_FAILURE,
data: { error },
});
});
};
}
export function doInstallNew(appVersion, callbackForUsersWhoAreSharingData, domain) {
const payload = { app_version: appVersion, domain };
Lbry.status().then((status) => {
payload.app_id =
domain && domain !== 'lbry.tv'
? (domain.replace(/[.]/gi, '') + status.installation_id).slice(0, 66)
: status.installation_id;
payload.node_id = status.lbry_id;
Lbry.version().then((version) => {
payload.daemon_version = version.lbrynet_version;
payload.operating_system = version.os_system;
payload.platform = version.platform;
Lbryio.call('install', 'new', payload);
if (callbackForUsersWhoAreSharingData) {
callbackForUsersWhoAreSharingData(status);
}
});
});
}
function checkAuthBusy() {
let time = Date.now();
return new Promise(function (resolve, reject) {
(function waitForAuth() {
try {
sessionStorage.setItem('test', 'available');
sessionStorage.removeItem('test');
sessionStorageAvailable = true;
} catch (e) {
if (e) {
// no session storage
}
}
if (!IS_WEB || !sessionStorageAvailable) {
return resolve();
}
const inProgress = LocalStorage.getItem(LS.AUTH_IN_PROGRESS);
if (!inProgress) {
LocalStorage.setItem(LS.AUTH_IN_PROGRESS, 'true');
return resolve();
} else {
if (Date.now() - time < AUTH_WAIT_TIMEOUT) {
setTimeout(waitForAuth, CHECK_INTERVAL);
} else {
return resolve();
}
}
})();
});
}
/***
* Given a user, return their highest ranking Odysee membership (Premium or Premium Plus)
* @param dispatch
* @param user
* @returns {Promise<void>}
*/
export function doCheckUserOdyseeMemberships(user) {
return async (dispatch) => {
let highestMembershipRanking;
if (user.odysee_member) {
// get memberships for a given user
// TODO: in the future, can we specify this just to @odysee?
const response = await Lbryio.call(
'membership',
'mine',
{
environment: stripeEnvironment,
},
'post'
);
let savedMemberships = [];
// TODO: this will work for now, but it should be adjusted
// TODO: to check if it's active, or if it's cancelled if it's still valid past current date
// loop through all memberships and save the @odysee ones
// maybe in the future we can only hit @odysee in the API call
for (const membership of response) {
if (membership.MembershipDetails && membership.MembershipDetails.channel_name === '@odysee') {
savedMemberships.push(membership.MembershipDetails.name);
}
}
// determine highest ranking membership based on returned data
// note: this is from an odd state in the API where a user can be both premium/Premium + at the same time
// I expect this can change once upgrade/downgrade is implemented
if (savedMemberships.length > 0) {
// if premium plus is a membership, return that, otherwise it's only premium
const premiumPlusExists = savedMemberships.includes('Premium+');
if (premiumPlusExists) {
highestMembershipRanking = 'Premium+';
} else {
highestMembershipRanking = 'Premium';
}
}
}
dispatch({
type: ACTIONS.ADD_ODYSEE_MEMBERSHIP_DATA,
data: { user, odyseeMembershipName: highestMembershipRanking || '' }, // '' = none; `undefined` = not fetched
});
};
}
// TODO: Call doInstallNew separately so we don't have to pass appVersion and os_system params?
export function doAuthenticate(
appVersion,
shareUsageData = true,
callbackForUsersWhoAreSharingData,
callInstall = true
) {
return (dispatch) => {
dispatch({
type: ACTIONS.AUTHENTICATION_STARTED,
});
checkAuthBusy()
.then(() => {
return Lbryio.authenticate(DOMAIN, getDefaultLanguage());
})
.then((user) => {
LocalStorage.removeItem(LS.AUTH_IN_PROGRESS);
Lbryio.getAuthToken().then((token) => {
dispatch({
type: ACTIONS.AUTHENTICATION_SUCCESS,
data: { user, accessToken: token },
});
dispatch(doCheckUserOdyseeMemberships(user));
if (shareUsageData) {
dispatch(doRewardList());
if (callInstall && !user?.device_types?.includes('web')) {
doInstallNew(appVersion, callbackForUsersWhoAreSharingData, DOMAIN);
}
}
dispatch(doFetchGeoBlockedList());
});
})
.catch((error) => {
LocalStorage.removeItem(LS.AUTH_IN_PROGRESS);
dispatch({
type: ACTIONS.AUTHENTICATION_FAILURE,
data: { error },
});
});
};
}
export function doUserFetch() {
return (dispatch) =>
new Promise((resolve, reject) => {
dispatch({
type: ACTIONS.USER_FETCH_STARTED,
});
Lbryio.getCurrentUser()
.then((user) => {
dispatch(doCheckUserOdyseeMemberships(user));
dispatch({
type: ACTIONS.USER_FETCH_SUCCESS,
data: { user },
});
resolve(user);
})
.catch((error) => {
reject(error);
dispatch({
type: ACTIONS.USER_FETCH_FAILURE,
data: { error },
});
});
});
}
export function doUserCheckEmailVerified() {
// This will happen in the background so we don't need loading booleans
return (dispatch) => {
Lbryio.getCurrentUser().then((user) => {
dispatch(doCheckUserOdyseeMemberships(user));
if (user.has_verified_email) {
dispatch(doRewardList());
dispatch({
type: ACTIONS.USER_FETCH_SUCCESS,
data: { user },
});
}
});
};
}
export function doUserPhoneReset() {
return {
type: ACTIONS.USER_PHONE_RESET,
};
}
export function doUserPhoneNew(phone, countryCode) {
return (dispatch) => {
dispatch({
type: ACTIONS.USER_PHONE_NEW_STARTED,
data: { phone, country_code: countryCode },
});
const success = () => {
dispatch({
type: ACTIONS.USER_PHONE_NEW_SUCCESS,
data: { phone },
});
};
const failure = (error) => {
dispatch({
type: ACTIONS.USER_PHONE_NEW_FAILURE,
data: { error },
});
};
Lbryio.call('user', 'phone_number_new', { phone_number: phone, country_code: countryCode }, 'post').then(
success,
failure
);
};
}
export function doUserPhoneVerifyFailure(error) {
return {
type: ACTIONS.USER_PHONE_VERIFY_FAILURE,
data: { error },
};
}
export function doUserPhoneVerify(verificationCode) {
return (dispatch, getState) => {
const phoneNumber = selectPhoneToVerify(getState());
const countryCode = selectUserCountryCode(getState());
dispatch({
type: ACTIONS.USER_PHONE_VERIFY_STARTED,
code: verificationCode,
});
Lbryio.call(
'user',
'phone_number_confirm',
{
verification_code: verificationCode,
phone_number: phoneNumber,
country_code: countryCode,
},
'post'
)
.then((user) => {
if (user.is_identity_verified) {
dispatch({
type: ACTIONS.USER_PHONE_VERIFY_SUCCESS,
data: { user },
});
dispatch(doClaimRewardType(rewards.TYPE_NEW_USER));
}
})
.catch((error) => dispatch(doUserPhoneVerifyFailure(error)));
};
}
export function doUserEmailToVerify(email) {
return (dispatch) => {
dispatch({
type: ACTIONS.USER_EMAIL_VERIFY_SET,
data: { email },
});
};
}
export function doUserEmailNew(email) {
return (dispatch) => {
dispatch({
type: ACTIONS.USER_EMAIL_NEW_STARTED,
email,
});
const success = () => {
dispatch({
type: ACTIONS.USER_EMAIL_NEW_SUCCESS,
data: { email },
});
dispatch(doUserFetch());
};
const failure = (error) => {
dispatch({
type: ACTIONS.USER_EMAIL_NEW_FAILURE,
data: { error },
});
};
Lbryio.call('user_email', 'new', { email, send_verification_email: true }, 'post')
.catch((error) => {
if (error.response && error.response.status === 409) {
dispatch({
type: ACTIONS.USER_EMAIL_NEW_EXISTS,
});
return Lbryio.call('user_email', 'resend_token', { email, only_if_expired: true }, 'post').then(
success,
failure
);
}
throw error;
})
.then(success, failure);
};
}
export function doUserCheckIfEmailExists(email) {
return (dispatch) => {
dispatch({
type: ACTIONS.USER_EMAIL_NEW_STARTED,
email,
});
const triggerEmailFlow = (hasPassword) => {
dispatch({
type: ACTIONS.USER_EMAIL_NEW_SUCCESS,
data: { email },
});
dispatch({
type: ACTIONS.USER_EMAIL_NEW_EXISTS,
});
if (hasPassword) {
dispatch({
type: ACTIONS.USER_PASSWORD_EXISTS,
});
} else {
// If they don't have a password, they will need to use the email verification api
Lbryio.call('user_email', 'resend_token', { email, only_if_expired: true }, 'post');
}
};
const success = (response) => {
triggerEmailFlow(response.has_password);
};
const failure = (error) =>
dispatch({
type: ACTIONS.USER_EMAIL_NEW_FAILURE,
data: { error },
});
Lbryio.call('user', 'exists', { email }, 'post')
.catch((error) => {
// no email
if (error.response && error.response.status === 404) {
dispatch({
type: ACTIONS.USER_EMAIL_NEW_DOES_NOT_EXIST,
});
// sign in by email
} else if (error.response && error.response.status === 412) {
triggerEmailFlow(false);
}
throw error;
})
// sign the user in
.then(success, failure);
};
}
export function doUserSignIn(email, password) {
return (dispatch) => {
dispatch({
type: ACTIONS.USER_EMAIL_NEW_STARTED,
email,
});
const success = () => {
dispatch({
type: ACTIONS.USER_EMAIL_NEW_SUCCESS,
data: { email },
});
dispatch(doUserFetch());
};
const failure = (error) =>
dispatch({
type: ACTIONS.USER_EMAIL_NEW_FAILURE,
data: { error },
});
Lbryio.call('user', 'signin', { email, ...(password ? { password } : {}) }, 'post')
.catch((error) => {
if (error.response && error.response.status === 409) {
dispatch({
type: ACTIONS.USER_EMAIL_NEW_EXISTS,
});
return Lbryio.call('user_email', 'resend_token', { email, only_if_expired: true }, 'post').then(
success,
failure
);
}
throw error;
})
.then(success, failure);
};
}
export function doUserSignUp(email, password) {
return (dispatch) =>
new Promise((resolve, reject) => {
dispatch({
type: ACTIONS.USER_EMAIL_NEW_STARTED,
email,
});
const success = () => {
dispatch({
type: ACTIONS.USER_EMAIL_NEW_SUCCESS,
data: { email },
});
dispatch(doUserFetch());
resolve();
};
const failure = (error) => {
if (error.response && error.response.status === 409) {
dispatch({
type: ACTIONS.USER_EMAIL_NEW_EXISTS,
});
}
dispatch({
type: ACTIONS.USER_EMAIL_NEW_FAILURE,
data: { error },
});
reject(error);
};
Lbryio.call('user', 'signup', { email, ...(password ? { password } : {}) }, 'post').then(success, failure);
});
}
export function doUserPasswordReset(email) {
return (dispatch) => {
dispatch({
type: ACTIONS.USER_PASSWORD_RESET_STARTED,
email,
});
const success = () => {
dispatch({
type: ACTIONS.USER_PASSWORD_RESET_SUCCESS,
});
};
const failure = (error) =>
dispatch({
type: ACTIONS.USER_PASSWORD_RESET_FAILURE,
data: { error },
});
Lbryio.call('user_password', 'reset', { email }, 'post').then(success, failure);
};
}
export function doUserPasswordSet(newPassword, oldPassword, authToken) {
return (dispatch) => {
dispatch({
type: ACTIONS.USER_PASSWORD_SET_STARTED,
});
const success = () => {
dispatch({
type: ACTIONS.USER_PASSWORD_SET_SUCCESS,
});
dispatch(doUserFetch());
};
const failure = (error) =>
dispatch({
type: ACTIONS.USER_PASSWORD_SET_FAILURE,
data: { error },
});
Lbryio.call(
'user_password',
'set',
{
new_password: newPassword,
...(oldPassword ? { old_password: oldPassword } : {}),
...(authToken ? { auth_token: authToken } : {}),
},
'post'
).then(success, failure);
};
}
export function doUserResendVerificationEmail(email) {
return (dispatch) => {
dispatch({
type: ACTIONS.USER_EMAIL_VERIFY_RETRY_STARTED,
});
const success = () => {
dispatch({
type: ACTIONS.USER_EMAIL_VERIFY_RETRY_SUCCESS,
});
};
const failure = (error) => {
dispatch({
type: ACTIONS.USER_EMAIL_VERIFY_RETRY_FAILURE,
data: { error },
});
};
Lbryio.call('user_email', 'resend_token', { email, only_if_expired: true }, 'post')
.catch((error) => {
if (error.response && error.response.status === 409) {
throw error;
}
})
.then(success, failure);
};
}
export function doClearEmailEntry() {
return {
type: ACTIONS.USER_EMAIL_NEW_CLEAR_ENTRY,
};
}
export function doClearPasswordEntry() {
return {
type: ACTIONS.USER_PASSWORD_SET_CLEAR,
};
}
export function doUserEmailVerifyFailure(error) {
return {
type: ACTIONS.USER_EMAIL_VERIFY_FAILURE,
data: { error },
};
}
export function doUserEmailVerify(verificationToken, recaptcha) {
return (dispatch, getState) => {
const email = selectEmailToVerify(getState());
dispatch({
type: ACTIONS.USER_EMAIL_VERIFY_STARTED,
code: verificationToken,
recaptcha,
});
Lbryio.call(
'user_email',
'confirm',
{
verification_token: verificationToken,
email,
recaptcha,
},
'post'
)
.then((userEmail) => {
if (userEmail.is_verified) {
dispatch({
type: ACTIONS.USER_EMAIL_VERIFY_SUCCESS,
data: { email },
});
dispatch(doUserFetch());
} else {
throw new Error('Your email is still not verified.'); // shouldn't happen
}
})
.catch((error) => dispatch(doUserEmailVerifyFailure(error)));
};
}
export function doUserIdentityVerify(stripeToken) {
return (dispatch) => {
dispatch({
type: ACTIONS.USER_IDENTITY_VERIFY_STARTED,
token: stripeToken,
});
Lbryio.call('user', 'verify_identity', { stripe_token: stripeToken }, 'post')
.then((user) => {
if (user.is_identity_verified) {
dispatch({
type: ACTIONS.USER_IDENTITY_VERIFY_SUCCESS,
data: { user },
});
dispatch(doClaimRewardType(rewards.TYPE_NEW_USER));
} else {
throw new Error('Your identity is still not verified. This should not happen.'); // shouldn't happen
}
})
.catch((error) => {
dispatch({
type: ACTIONS.USER_IDENTITY_VERIFY_FAILURE,
data: { error: error.toString() },
});
});
};
}
export function doUserInviteNew(email) {
return (dispatch) => {
dispatch({
type: ACTIONS.USER_INVITE_NEW_STARTED,
});
return Lbryio.call('user', 'invite', { email }, 'post')
.then((success) => {
dispatch({
type: ACTIONS.USER_INVITE_NEW_SUCCESS,
data: { email },
});
dispatch(
doToast({
message: __('Invite sent to %email_address%', { email_address: email }),
})
);
dispatch(doFetchInviteStatus());
return success;
})
.catch((error) => {
dispatch({
type: ACTIONS.USER_INVITE_NEW_FAILURE,
data: { error },
});
});
};
}
export function doUserSetReferrerReset() {
return (dispatch) => {
dispatch({
type: ACTIONS.USER_SET_REFERRER_RESET,
});
};
}
export function doUserSetReferrerWithUri(uri) {
return async (dispatch, getState) => {
const state = getState();
let claim = selectClaimForUri(state, uri);
let referrerCode;
if (!claim) {
try {
const response = await Lbry.resolve({ urls: [uri] });
if (response && response[uri] && !response[uri].error) claim = response && response[uri];
if (claim) {
if (claim.signing_channel) {
referrerCode = claim.signing_channel.permanent_url.replace('lbry://', '');
} else {
referrerCode = claim.permanent_url.replace('lbry://', '');
}
const isRewardApproved = selectIsRewardApproved(state);
dispatch(doUserSetReferrer(referrerCode, isRewardApproved));
}
} catch (error) {
dispatch({
type: ACTIONS.USER_SET_REFERRER_FAILURE,
data: { error },
});
}
} else {
referrerCode = claim.permanent_url.replace('lbry://', '');
const isRewardApproved = selectIsRewardApproved(state);
dispatch(doUserSetReferrer(referrerCode, isRewardApproved));
}
};
}
export function doUserSetReferrer(referrer, shouldClaim) {
return async (dispatch, getState) => {
dispatch({
type: ACTIONS.USER_SET_REFERRER_STARTED,
});
let claim, referrerCode;
const isValid = isURIValid(referrer);
if (isValid) {
const state = getState();
const uri = normalizeURI(referrer);
claim = selectClaimForUri(state, uri);
if (!claim) {
try {
const response = await Lbry.resolve({ urls: [uri] });
if (response && response[uri] && !response[uri].error) claim = response && response[uri];
if (claim) {
if (claim.signing_channel) {
referrerCode = claim.signing_channel.permanent_url.replace('lbry://', '');
} else {
referrerCode = claim.permanent_url.replace('lbry://', '');
}
}
} catch (error) {
dispatch({
type: ACTIONS.USER_SET_REFERRER_FAILURE,
data: { error },
});
}
} else {
referrerCode = claim.permanent_url.replace('lbry://', '');
}
}
if (!referrerCode) {
referrerCode = referrer;
}
try {
await Lbryio.call('user', 'referral', { referrer: referrerCode }, 'post');
dispatch({
type: ACTIONS.USER_SET_REFERRER_SUCCESS,
});
if (shouldClaim) {
dispatch(doClaimRewardType(rewards.TYPE_REFEREE));
dispatch(doUserFetch());
} else {
dispatch(doUserFetch());
}
} catch (error) {
dispatch({
type: ACTIONS.USER_SET_REFERRER_FAILURE,
data: { error },
});
}
};
}
export function doUserSetCountry(country) {
return (dispatch, getState) => {
const state = getState();
const user = selectUser(state);
Lbryio.call('user_country', 'set', { country }).then(() => {
const newUser = { ...user, country };
dispatch({
type: ACTIONS.USER_FETCH_SUCCESS,
data: { user: newUser },
});
});
};
}
export function doClaimYoutubeChannels() {
return (dispatch) => {
dispatch({
type: ACTIONS.USER_YOUTUBE_IMPORT_STARTED,
});
let transferResponse;
return Lbry.address_list({ page: 1, page_size: 99999 })
.then((addressList) => addressList.items[0])
.then((address) =>
Lbryio.call('yt', 'transfer', {
address: address.address,
public_key: address.pubkey,
}).then((response) => {
if (response && response.length) {
transferResponse = response;
return Promise.all(
response.map((channelMeta) => {
if (channelMeta && channelMeta.channel && channelMeta.channel.channel_certificate) {
return Lbry.channel_import({
channel_data: channelMeta.channel.channel_certificate,
});
}
return null;
})
).then(() => {
const actions = [
{
type: ACTIONS.USER_YOUTUBE_IMPORT_SUCCESS,
data: transferResponse,
},
];
actions.push(doUserFetch());
actions.push(doFetchChannelListMine());
dispatch(batchActions(...actions));
});
}
})
)
.catch((error) => {
dispatch({
type: ACTIONS.USER_YOUTUBE_IMPORT_FAILURE,
data: String(error),
});
});
};
}
export function doCheckYoutubeTransfer() {
return (dispatch) => {
dispatch({
type: ACTIONS.USER_YOUTUBE_IMPORT_STARTED,
});
return Lbryio.call('yt', 'transfer')
.then((response) => {
if (response && response.length) {
dispatch({
type: ACTIONS.USER_YOUTUBE_IMPORT_SUCCESS,
data: response,
});
} else {
throw new Error();
}
})
.catch((error) => {
dispatch({
type: ACTIONS.USER_YOUTUBE_IMPORT_FAILURE,
data: String(error),
});
});
};
}
/***
* Receives a csv of channel claim ids, hits the backend and returns nicely formatted object with relevant info
* @param claimIdCsv
* @returns {(function(*): Promise<void>)|*}
*/
export function doFetchUserMemberships(claimIdCsv) {
return async (dispatch) => {
if (!claimIdCsv || (claimIdCsv.length && claimIdCsv.length < 1)) return;
// check if users have odysee memberships (premium/premium+)
const response = await Lbryio.call('membership', 'check', {
channel_id: ODYSEE_CHANNEL.ID,
claim_ids: claimIdCsv,
environment: stripeEnvironment,
});
let updatedResponse = {};
// loop through returned users
for (const user in response) {
// if array was returned for a user (indicating a membership exists), otherwise is null
if (response[user] && response[user].length) {
// get membership for user
// note: a for loop is kind of odd, indicates there may be multiple memberships?
// probably not needed depending on what we do with the frontend, should revisit
for (const membership of response[user]) {
if (membership.channel_name) {
updatedResponse[user] = membership.name;
window.checkedMemberships[user] = membership.name;
}
}
} else {
// note the user has been fetched but is null
updatedResponse[user] = null;
window.checkedMemberships[user] = null;
}
}
dispatch({ type: ACTIONS.ADD_CLAIMIDS_MEMBERSHIP_DATA, data: { response: updatedResponse } });
};
}
export function doFetchUserLocale(isRetry = false) {
return (dispatch) => {
fetch(LOCALE_API)
.then((res) => res.json())
.then((json) => {
const locale = json.data; // [flow] local: LocaleInfo
if (locale) {
dispatch({
type: ACTIONS.USER_FETCH_LOCALE_DONE,
data: locale,
});
}
})
.catch(() => {
if (!isRetry) {
// If failed, retry one more time after N seconds. This removes the
// need to fetch at each component level. If it failed twice, probably
// don't need to fetch anymore.
setTimeout(() => {
dispatch(doFetchUserLocale(true));
}, 10000);
}
});
};
}