diff --git a/package.json b/package.json index 433fa8624..fc8593f2e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dev:web": "cd ./lbrytv && yarn dev", "dev:web-server": "cross-env NODE_ENV=development yarn compile:web && concurrently \"cross-env NODE_ENV=development yarn compile:web --watch\" \"cd ./lbrytv && yarn dev:server\"", "dev:internal-apis": "LBRY_API_URL='http://localhost:8080' yarn dev:electron", + "dev:iatv": "LBRY_API_URL='http://localhost:15400' SDK_API_URL='http://localhost:15100' yarn dev:web", "run:web": "cross-env NODE_ENV=production yarn compile:web && node ./dist/web/server.js", "pack": "electron-builder --dir", "dist": "electron-builder", @@ -67,7 +68,7 @@ "@babel/register": "^7.0.0", "@exponent/electron-cookies": "^2.0.0", "@hot-loader/react-dom": "^16.8", - "@lbry/components": "^3.0.5", + "@lbry/components": "^3.0.6", "@reach/menu-button": "^0.1.18", "@reach/rect": "^0.2.1", "@reach/tabs": "^0.1.5", @@ -128,7 +129,7 @@ "json-loader": "^0.5.4", "lbry-format": "https://github.com/lbryio/lbry-format.git", "lbry-redux": "lbryio/lbry-redux#a2be979986dc93be4c2c596846109f5394f64fa1", - "lbryinc": "lbryio/lbryinc#6042c6f7bbf5fe7c6db2bd169f5b1c4558485c4c", + "lbryinc": "lbryio/lbryinc#1912aa1834f83a5a43e028327d35bd64cfba528e", "lint-staged": "^7.0.2", "localforage": "^1.7.1", "lodash-es": "^4.17.14", @@ -158,8 +159,8 @@ "react-modal": "^3.1.7", "react-paginate": "^5.2.1", "react-redux": "^6.0.1", - "react-router": "^5.0.0", - "react-router-dom": "^5.0.0", + "react-router": "^5.1.0", + "react-router-dom": "^5.1.0", "react-simplemde-editor": "^4.0.0", "react-spring": "^8.0.20", "react-sticky-box": "^0.8.0", diff --git a/ui/component/app/index.js b/ui/component/app/index.js index 33cc17d4a..52ac5bf2d 100644 --- a/ui/component/app/index.js +++ b/ui/component/app/index.js @@ -1,16 +1,31 @@ import * as SETTINGS from 'constants/settings'; import { hot } from 'react-hot-loader/root'; import { connect } from 'react-redux'; -import { selectUser, doRewardList, doFetchAccessToken, selectGetSyncErrorMessage, selectUploadCount } from 'lbryinc'; +import { + selectUser, + selectAccessToken, + doRewardList, + doFetchAccessToken, + selectGetSyncErrorMessage, + selectUploadCount, + selectUnclaimedRewards, + doUserSetReferrer, +} from 'lbryinc'; import { doFetchTransactions, doFetchChannelListMine } from 'lbry-redux'; import { makeSelectClientSetting, selectLoadedLanguages, selectThemePath } from 'redux/selectors/settings'; import { selectIsUpgradeAvailable, selectAutoUpdateDownloaded } from 'redux/selectors/app'; import { doSetLanguage } from 'redux/actions/settings'; -import { doDownloadUpgradeRequested, doSignIn, doSyncWithPreferences, doGetAndPopulatePreferences } from 'redux/actions/app'; +import { + doDownloadUpgradeRequested, + doSignIn, + doSyncWithPreferences, + doGetAndPopulatePreferences, +} from 'redux/actions/app'; import App from './view'; const select = state => ({ user: selectUser(state), + accessToken: selectAccessToken(state), theme: selectThemePath(state), language: makeSelectClientSetting(SETTINGS.LANGUAGE)(state), languages: selectLoadedLanguages(state), @@ -19,6 +34,7 @@ const select = state => ({ syncEnabled: makeSelectClientSetting(SETTINGS.ENABLE_SYNC)(state), syncError: selectGetSyncErrorMessage(state), uploadCount: selectUploadCount(state), + rewards: selectUnclaimedRewards(state), }); const perform = dispatch => ({ @@ -31,6 +47,7 @@ const perform = dispatch => ({ requestDownloadUpgrade: () => dispatch(doDownloadUpgradeRequested()), checkSync: () => dispatch(doSyncWithPreferences()), updatePreferences: () => dispatch(doGetAndPopulatePreferences()), + setReferrer: (referrer, doClaim) => dispatch(doUserSetReferrer(referrer, doClaim)), }); export default hot( diff --git a/ui/component/app/view.jsx b/ui/component/app/view.jsx index b439c8490..b63e5e14e 100644 --- a/ui/component/app/view.jsx +++ b/ui/component/app/view.jsx @@ -14,6 +14,7 @@ import FloatingViewer from 'component/floatingViewer'; import { withRouter } from 'react-router'; import usePrevious from 'effects/use-previous'; import Nag from 'component/common/nag'; +import { rewards as REWARDS } from 'lbryinc'; // @if TARGET='web' import OpenInAppLink from 'component/openInAppLink'; import YoutubeWelcome from 'component/youtubeWelcome'; @@ -32,7 +33,7 @@ type Props = { languages: Array, theme: string, user: ?{ id: string, has_verified_email: boolean, is_reward_approved: boolean }, - location: { pathname: string, hash: string }, + location: { pathname: string, hash: string, search: string }, history: { push: string => void }, fetchRewards: () => void, fetchTransactions: (number, number) => void, @@ -40,7 +41,6 @@ type Props = { fetchChannelListMine: () => void, signIn: () => void, requestDownloadUpgrade: () => void, - fetchChannelListMine: () => void, onSignedIn: () => void, setLanguage: string => void, isUpgradeAvailable: boolean, @@ -50,8 +50,9 @@ type Props = { syncEnabled: boolean, uploadCount: number, balance: ?number, - accessToken: ?string, syncError: ?string, + rewards: Array, + setReferrer: (string, boolean) => void, }; function App(props: Props) { @@ -75,6 +76,8 @@ function App(props: Props) { languages, setLanguage, updatePreferences, + rewards, + setReferrer, } = props; const appRef = useRef(); @@ -86,8 +89,13 @@ function App(props: Props) { const previousUserId = usePrevious(userId); const previousHasVerifiedEmail = usePrevious(hasVerifiedEmail); const previousRewardApproved = usePrevious(isRewardApproved); - const { pathname, hash } = props.location; + const { pathname, hash, search } = props.location; const showUpgradeButton = autoUpdateDownloaded || (process.platform === 'linux' && isUpgradeAvailable); + // referral claiming + const referredRewardAvailable = rewards && rewards.some(reward => reward.reward_type === REWARDS.TYPE_REFEREE); + const urlParams = new URLSearchParams(search); + const rawReferrerParam = urlParams.get('r'); + const sanitizedReferrerParam = rawReferrerParam && rawReferrerParam.replace(':', '#'); let uri; try { @@ -105,6 +113,14 @@ function App(props: Props) { return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [uploadCount]); + useEffect(() => { + if (referredRewardAvailable && sanitizedReferrerParam && isRewardApproved) { + setReferrer(sanitizedReferrerParam, true); + } else if (referredRewardAvailable && sanitizedReferrerParam) { + setReferrer(sanitizedReferrerParam, false); + } + }, [sanitizedReferrerParam, isRewardApproved, referredRewardAvailable]); + useEffect(() => { ReactModal.setAppElement(appRef.current); fetchAccessToken(); diff --git a/ui/component/claimPreview/index.js b/ui/component/claimPreview/index.js index 68aed8ac7..fa6fd6c18 100644 --- a/ui/component/claimPreview/index.js +++ b/ui/component/claimPreview/index.js @@ -7,6 +7,7 @@ import { makeSelectClaimIsMine, makeSelectClaimIsPending, makeSelectThumbnailForUri, + makeSelectCoverForUri, makeSelectTitleForUri, makeSelectClaimIsNsfw, selectBlockedChannels, @@ -32,6 +33,7 @@ const select = (state, props) => ({ claimIsMine: props.uri && makeSelectClaimIsMine(props.uri)(state), isResolvingUri: props.uri && makeSelectIsUriResolving(props.uri)(state), thumbnail: props.uri && makeSelectThumbnailForUri(props.uri)(state), + cover: props.uri && makeSelectCoverForUri(props.uri)(state), title: props.uri && makeSelectTitleForUri(props.uri)(state), mediaType: makeSelectMediaTypeForUri(props.uri)(state), nsfw: props.uri && makeSelectClaimIsNsfw(props.uri)(state), diff --git a/ui/component/header/view.jsx b/ui/component/header/view.jsx index d8f26c3e9..9ab0277e1 100644 --- a/ui/component/header/view.jsx +++ b/ui/component/header/view.jsx @@ -183,10 +183,10 @@ const Header = (props: Props) => { {/* Commented out until new invite system is implemented */} - {/* history.push(`/$/${PAGES.INVITE}`)}> + history.push(`/$/${PAGES.INVITE}`)}> - {__('Invite A Friend')} - */} + {__('Invites')} + {authenticated ? ( diff --git a/ui/component/inviteNew/index.js b/ui/component/inviteNew/index.js index 24a85d94a..065a298f4 100644 --- a/ui/component/inviteNew/index.js +++ b/ui/component/inviteNew/index.js @@ -4,19 +4,33 @@ import { selectUserInviteNewIsPending, selectUserInviteNewErrorMessage, selectUserInviteReferralLink, + selectUserInviteReferralCode, doUserInviteNew, } from 'lbryinc'; +import { + selectMyChannelClaims, + selectFetchingMyChannels, + doFetchChannelListMine, + doResolveUris, + selectResolvingUris, +} from 'lbry-redux'; import InviteNew from './view'; const select = state => ({ errorMessage: selectUserInviteNewErrorMessage(state), invitesRemaining: selectUserInvitesRemaining(state), referralLink: selectUserInviteReferralLink(state), + referralCode: selectUserInviteReferralCode(state), isPending: selectUserInviteNewIsPending(state), + channels: selectMyChannelClaims(state), + fetchingChannels: selectFetchingMyChannels(state), + resolvingUris: selectResolvingUris(state), }); const perform = dispatch => ({ inviteNew: email => dispatch(doUserInviteNew(email)), + fetchChannelListMine: () => dispatch(doFetchChannelListMine()), + resolveUris: uris => dispatch(doResolveUris(uris)), }); export default connect( diff --git a/ui/component/inviteNew/view.jsx b/ui/component/inviteNew/view.jsx index 69a206381..f6b2a7d3e 100644 --- a/ui/component/inviteNew/view.jsx +++ b/ui/component/inviteNew/view.jsx @@ -1,71 +1,154 @@ // @flow -import React from 'react'; +import React, { useEffect, useState } from 'react'; import Button from 'component/button'; import { Form, FormField } from 'component/common/form'; import CopyableText from 'component/copyableText'; import Card from 'component/common/card'; +import { URL } from 'config'; +import SelectChannel from 'component/selectChannel'; +import analytics from 'analytics'; import I18nMessage from 'component/i18nMessage'; -type FormState = { - email: string, -}; - type Props = { errorMessage: ?string, inviteNew: string => void, isPending: boolean, referralLink: string, + referralCode: string, + channels: ?Array, + resolvingUris: Array, + resolveUris: (Array) => void, }; -class InviteNew extends React.PureComponent { - constructor() { - super(); +function InviteNew(props: Props) { + const { inviteNew, errorMessage, isPending, referralCode = '', channels, resolveUris, resolvingUris } = props; + const rewardAmount = 20; - this.state = { - email: '', - }; - - (this: any).handleSubmit = this.handleSubmit.bind(this); + // Email + const [email, setEmail] = useState(''); + function handleSubmit() { + inviteNew(email); } - handleEmailChanged(event: any) { - this.setState({ - email: event.target.value, + function handleEmailChanged(event: any) { + setEmail(event.target.value); + } + + // Referral link + const [referralSource, setReferralSource] = useState(referralCode); + /* Canonical Referral links + * We need to make sure our channels are resolved so that canonical_url is present + */ + + function handleReferralChange(code) { + setReferralSource(code); + // TODO: keep track of this in an array? + const matchingChannel = channels && channels.find(ch => ch.name === code); + if (matchingChannel) { + analytics.apiLogPublish(matchingChannel); + } + } + + const [resolveStarted, setResolveStarted] = useState(false); + const [hasResolved, setHasResolved] = useState(false); + // join them so that useEffect doesn't update on new objects + const uris = channels && channels.map(channel => channel.permanent_url).join(','); + const channelCount = channels && channels.length; + const resolvingCount = resolvingUris && resolvingUris.length; + + const topChannel = + channels && + channels.reduce((top, channel) => { + const topClaimCount = (top && top.meta && top.meta.claims_in_channel) || 0; + const currentClaimCount = (channel && channel.meta && channel.meta.claims_in_channel) || 0; + return topClaimCount >= currentClaimCount ? top : channel; }); + const referralString = + channels && channels.length && referralSource !== referralCode + ? lookupUrlByClaimName(referralSource, channels) + : referralSource; + + const referral = `${URL}/$/invite/${referralString.replace('#', ':')}`; + + useEffect(() => { + // resolve once, after we have channel list + if (!hasResolved && !resolveStarted && channelCount && uris) { + setResolveStarted(true); + resolveUris(uris.split(',')); + } + }, [channelCount, resolveStarted, hasResolved, resolvingCount, uris]); + + useEffect(() => { + // once resolving count is 0, we know we're done + if (resolveStarted && !hasResolved && resolvingCount === 0) { + setHasResolved(true); + } + }, [resolveStarted, hasResolved, resolvingCount]); + + useEffect(() => { + // set default channel + if (topChannel && hasResolved) { + handleReferralChange(topChannel.name); + } + }, [topChannel, hasResolved]); + + function lookupUrlByClaimName(name, channels) { + const claim = channels.find(channel => channel.name === name); + return claim && claim.canonical_url ? claim.canonical_url.replace('lbry://', '') : name; } - handleSubmit() { - const { email } = this.state; - this.props.inviteNew(email); - } - - render() { - const { errorMessage, isPending, referralLink } = this.props; - - return ( + return ( +
-
+ + handleReferralChange(channel)} + label={'Customize link'} + hideAnon + injected={[referralCode]} + /> + +

+ , + referral_faq_link:

+ ); } export default InviteNew; diff --git a/ui/component/invited/index.js b/ui/component/invited/index.js new file mode 100644 index 000000000..84c04b498 --- /dev/null +++ b/ui/component/invited/index.js @@ -0,0 +1,39 @@ +import { connect } from 'react-redux'; +import { + selectUser, + doClaimRewardType, + doUserSetReferrer, + selectSetReferrerPending, + selectSetReferrerError, + rewards as REWARDS, + selectUnclaimedRewards, +} from 'lbryinc'; +import { makeSelectIsSubscribed } from 'redux/selectors/subscriptions'; +import { doChannelSubscribe } from 'redux/actions/subscriptions'; +import Invited from './view'; +import { withRouter } from 'react-router'; + +const select = (state, props) => { + return { + user: selectUser(state), + referrerSetPending: selectSetReferrerPending(state), + referrerSetError: selectSetReferrerError(state), + rewards: selectUnclaimedRewards(state), + isSubscribed: makeSelectIsSubscribed(props.fullUri)(state), + fullUri: props.fullUri, + referrer: props.referrer, + }; +}; + +const perform = dispatch => ({ + claimReward: () => dispatch(doClaimRewardType(REWARDS.TYPE_REFEREE)), + setReferrer: referrer => dispatch(doUserSetReferrer(referrer)), + channelSubscribe: uri => dispatch(doChannelSubscribe(uri)), +}); + +export default withRouter( + connect( + select, + perform + )(Invited) +); diff --git a/ui/component/invited/view.jsx b/ui/component/invited/view.jsx new file mode 100644 index 000000000..06eafc896 --- /dev/null +++ b/ui/component/invited/view.jsx @@ -0,0 +1,173 @@ +// @flow +import * as PAGES from 'constants/pages'; +import React, { useEffect } from 'react'; +import Button from 'component/button'; +import ClaimPreview from 'component/claimPreview'; +import Card from 'component/common/card'; +import { parseURI } from 'lbry-redux'; +import { rewards as REWARDS, ERRORS } from 'lbryinc'; + +type Props = { + user: any, + claimReward: () => void, + setReferrer: string => void, + referrerSetPending: boolean, + referrerSetError: string, + channelSubscribe: (sub: Subscription) => void, + history: { push: string => void }, + rewards: Array, + referrer: string, + fullUri: string, + isSubscribed: boolean, +}; + +function Invited(props: Props) { + const { + user, + claimReward, + setReferrer, + referrerSetPending, + referrerSetError, + channelSubscribe, + history, + rewards, + fullUri, + referrer, + isSubscribed, + } = props; + + const refUri = referrer && 'lbry://' + referrer.replace(':', '#'); + const referrerIsChannel = parseURI(refUri).isChannel; + const rewardsApproved = user && user.is_reward_approved; + const hasVerifiedEmail = user && user.has_verified_email; + const referredRewardAvailable = rewards && rewards.some(reward => reward.reward_type === REWARDS.TYPE_REFEREE); + + // always follow if it's a channel + useEffect(() => { + if (fullUri && !isSubscribed) { + channelSubscribe({ + channelName: parseURI(fullUri).claimName, + uri: fullUri, + }); + } + }, [fullUri, isSubscribed]); + + useEffect(() => { + if (!referrerSetPending && hasVerifiedEmail) { + claimReward(); + } + }, [referrerSetPending, hasVerifiedEmail]); + + useEffect(() => { + if (referrer) { + setReferrer(referrer.replace(':', '#')); + } + }, [referrer]); + + function handleDone() { + history.push(`/$/${PAGES.DISCOVER}`); + } + + if (referrerSetError === ERRORS.ALREADY_CLAIMED) { + return ( + + + + ) + } + actions={ +
+
+ } + /> + ); + } + + if (referrerSetError && referredRewardAvailable) { + return ( + +

{__('Not a valid invite')}

+
+
+ + } + /> + ); + } + + if (!rewardsApproved) { + return ( + + + + ) + } + actions={ +
+
+ } + /> + ); + } + + return ( + + + + ) + } + actions={ +
+
+ } + /> + ); +} + +export default Invited; diff --git a/ui/component/rewardTile/index.js b/ui/component/rewardTile/index.js index 08ad8e3f2..9fe7fb907 100644 --- a/ui/component/rewardTile/index.js +++ b/ui/component/rewardTile/index.js @@ -2,12 +2,17 @@ import * as MODALS from 'constants/modal_types'; import { connect } from 'react-redux'; import { doOpenModal } from 'redux/actions/app'; import RewardTile from './view'; +import { selectUser } from 'lbryinc'; +const select = state => ({ + user: selectUser(state), +}); const perform = dispatch => ({ openRewardCodeModal: () => dispatch(doOpenModal(MODALS.REWARD_GENERATED_CODE)), + openSetReferrerModal: () => dispatch(doOpenModal(MODALS.SET_REFERRER)), }); export default connect( - null, + select, perform )(RewardTile); diff --git a/ui/component/rewardTile/view.jsx b/ui/component/rewardTile/view.jsx index 90446082b..33a6df19b 100644 --- a/ui/component/rewardTile/view.jsx +++ b/ui/component/rewardTile/view.jsx @@ -9,6 +9,7 @@ import { rewards } from 'lbryinc'; type Props = { openRewardCodeModal: () => void, + openSetReferrerModal: () => void, reward: { id: string, reward_title: string, @@ -19,10 +20,12 @@ type Props = { reward_description: string, reward_type: string, }, + user: User, }; const RewardTile = (props: Props) => { - const { reward, openRewardCodeModal } = props; + const { reward, openRewardCodeModal, openSetReferrerModal, user } = props; + const referrerSet = user && user.invited_by_id; const claimed = !!reward.transaction_id; return ( @@ -35,7 +38,14 @@ const RewardTile = (props: Props) => {