Mobile rewards (#251)

This commit is contained in:
Akinwale Ariwodola 2018-08-28 11:59:14 +01:00 committed by GitHub
parent 050fc2b29e
commit c25785ccf4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 961 additions and 78 deletions

35
app/package-lock.json generated
View file

@ -3966,20 +3966,11 @@
} }
}, },
"lbryinc": { "lbryinc": {
"version": "github:lbryio/lbryinc#8e33473daa56ebe80b12509ac5374c5884efef90", "version": "github:lbryio/lbryinc#f5d23dc5ee80198bee8e859bb8487c1137f9fbef",
"from": "github:lbryio/lbryinc#authentication-flow", "from": "github:lbryio/lbryinc#rewards",
"requires": { "requires": {
"lbry-redux": "github:lbryio/lbry-redux#31f7afa8a37f5741dac01fc1ecdf153f3bed95dc",
"reselect": "^3.0.0" "reselect": "^3.0.0"
},
"dependencies": {
"lbry-redux": {
"version": "github:lbryio/lbry-redux#467e48c77b8004cef738e950bdcc67654748ae9f",
"from": "github:lbryio/lbry-redux#467e48c77b8004cef738e950bdcc67654748ae9f",
"requires": {
"proxy-polyfill": "0.1.6",
"reselect": "^3.0.0"
}
}
} }
}, },
"lcid": { "lcid": {
@ -5277,6 +5268,13 @@
"which-module": "^2.0.0", "which-module": "^2.0.0",
"y18n": "^3.2.1", "y18n": "^3.2.1",
"yargs-parser": "^7.0.0" "yargs-parser": "^7.0.0"
},
"dependencies": {
"y18n": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz",
"integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE="
}
} }
} }
} }
@ -6843,9 +6841,9 @@
"integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68="
}, },
"y18n": { "y18n": {
"version": "3.2.1", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
"integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=" "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w=="
}, },
"yallist": { "yallist": {
"version": "2.1.2", "version": "2.1.2",
@ -6870,6 +6868,13 @@
"which-module": "^2.0.0", "which-module": "^2.0.0",
"y18n": "^3.2.1", "y18n": "^3.2.1",
"yargs-parser": "^7.0.0" "yargs-parser": "^7.0.0"
},
"dependencies": {
"y18n": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz",
"integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE="
}
} }
}, },
"yargs-parser": { "yargs-parser": {

View file

@ -8,7 +8,7 @@
"dependencies": { "dependencies": {
"base-64": "^0.1.0", "base-64": "^0.1.0",
"lbry-redux": "lbryio/lbry-redux", "lbry-redux": "lbryio/lbry-redux",
"lbryinc": "lbryio/lbryinc#authentication-flow", "lbryinc": "lbryio/lbryinc#rewards",
"moment": "^2.22.1", "moment": "^2.22.1",
"react": "16.2.0", "react": "16.2.0",
"react-native": "0.55.3", "react-native": "0.55.3",

View file

@ -3,8 +3,9 @@ import AboutPage from '../page/about';
import DiscoverPage from '../page/discover'; import DiscoverPage from '../page/discover';
import FilePage from '../page/file'; import FilePage from '../page/file';
import FirstRunScreen from '../page/firstRun'; import FirstRunScreen from '../page/firstRun';
import SearchPage from '../page/search'; import RewardsPage from '../page/rewards';
import TrendingPage from '../page/trending'; import TrendingPage from '../page/trending';
import SearchPage from '../page/search';
import SettingsPage from '../page/settings'; import SettingsPage from '../page/settings';
import SplashScreen from '../page/splash'; import SplashScreen from '../page/splash';
import TransactionHistoryPage from '../page/transactionHistory'; import TransactionHistoryPage from '../page/transactionHistory';
@ -99,10 +100,21 @@ const walletStack = StackNavigator({
headerMode: 'screen' headerMode: 'screen'
}); });
const rewardsStack = StackNavigator({
Rewards: {
screen: RewardsPage,
navigationOptions: ({ navigation }) => ({
title: 'Rewards',
headerLeft: <Icon name="bars" size={24} style={discoverStyle.drawerHamburger} onPress={() => navigation.navigate('DrawerOpen')} />,
})
}
});
const drawer = DrawerNavigator({ const drawer = DrawerNavigator({
DiscoverStack: { screen: discoverStack }, DiscoverStack: { screen: discoverStack },
TrendingStack: { screen: trendingStack }, TrendingStack: { screen: trendingStack },
WalletStack: { screen: walletStack }, WalletStack: { screen: walletStack },
Rewards: { screen: rewardsStack },
Settings: { screen: SettingsPage, navigationOptions: { drawerLockMode: 'locked-closed' } }, Settings: { screen: SettingsPage, navigationOptions: { drawerLockMode: 'locked-closed' } },
About: { screen: AboutPage, navigationOptions: { drawerLockMode: 'locked-closed' } } About: { screen: AboutPage, navigationOptions: { drawerLockMode: 'locked-closed' } }
}, { }, {
@ -189,7 +201,7 @@ class AppWithNavigationState extends React.Component {
if (notification) { if (notification) {
const { displayType, message } = notification; const { displayType, message } = notification;
let currentDisplayType; let currentDisplayType;
if (displayType.length) { if (displayType && displayType.length) {
for (let i = 0; i < displayType.length; i++) { for (let i = 0; i < displayType.length; i++) {
const type = displayType[i]; const type = displayType[i];
if (AppWithNavigationState.supportedDisplayTypes.indexOf(type) > -1) { if (AppWithNavigationState.supportedDisplayTypes.indexOf(type) > -1) {
@ -201,6 +213,11 @@ class AppWithNavigationState extends React.Component {
currentDisplayType = displayType; currentDisplayType = displayType;
} }
if (!currentDisplayType && message) {
// default to toast if no display type set and there is a message specified
currentDisplayType = 'toast';
}
if ('toast' === currentDisplayType) { if ('toast' === currentDisplayType) {
ToastAndroid.show(message, ToastAndroid.SHORT); ToastAndroid.show(message, ToastAndroid.SHORT);
} }

View file

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import { doNotify } from 'lbry-redux';
import DeviceIdRewardSubcard from './view';
const perform = dispatch => ({
notify: data => dispatch(doNotify(data))
});
export default connect(null, perform)(DeviceIdRewardSubcard);

View file

@ -0,0 +1,48 @@
// @flow
import React from 'react';
import {
ActivityIndicator,
AsyncStorage,
NativeModules,
Text,
TextInput,
TouchableOpacity,
View
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome5';
import Button from '../button';
import Colors from '../../styles/colors';
import Constants from '../../constants';
import Link from '../link';
import rewardStyle from '../../styles/reward';
class DeviceIdRewardSubcard extends React.PureComponent {
onAllowAccessPressed = () => {
if (!NativeModules.UtilityModule) {
return notify({
message: 'The device ID could not be obtained due to a missing module.',
displayType: ['toast']
});
}
NativeModules.UtilityModule.requestPhoneStatePermission();
}
render() {
return (
<View style={rewardStyle.subcard}>
<Text style={rewardStyle.subtitle}>Pending action: Device ID</Text>
<Text style={[rewardStyle.bottomMarginMedium, rewardStyle.subcardText]}>
The app requires the phone state permission in order to identify your device for reward eligibility.
</Text>
<Button
style={rewardStyle.actionButton}
text={"Allow Access"}
onPress={this.onAllowAccessPressed}
/>
</View>
);
}
};
export default DeviceIdRewardSubcard;

View file

@ -0,0 +1,22 @@
import { connect } from 'react-redux';
import {
doUserEmailNew,
selectEmailNewErrorMessage,
selectEmailNewIsPending,
selectEmailToVerify
} from 'lbryinc';
import { doNotify } from 'lbry-redux';
import EmailRewardSubcard from './view';
const select = state => ({
emailToVerify: selectEmailToVerify(state),
emailNewErrorMessage: selectEmailNewErrorMessage(state),
emailNewPending: selectEmailNewIsPending(state)
});
const perform = dispatch => ({
addUserEmail: email => dispatch(doUserEmailNew(email)),
notify: data => dispatch(doNotify(data))
});
export default connect(select, perform)(EmailRewardSubcard);

View file

@ -0,0 +1,97 @@
// @flow
import React from 'react';
import {
ActivityIndicator,
AsyncStorage,
Text,
TextInput,
TouchableOpacity,
View
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome5';
import Button from '../button';
import Colors from '../../styles/colors';
import Constants from '../../constants';
import Link from '../link';
import rewardStyle from '../../styles/reward';
class EmailRewardSubcard extends React.PureComponent {
state = {
email: null,
verfiyStarted: false
};
componentDidMount() {
const { emailToVerify } = this.props;
AsyncStorage.getItem(Constants.KEY_FIRST_RUN_EMAIL).then(email => {
if (email && email.trim().length > 0) {
this.setState({ email });
} else {
this.setState({ email: emailToVerify });
}
});
}
componentWillReceiveProps(nextProps) {
const { emailNewErrorMessage, emailNewPending } = nextProps;
const { notify } = this.props;
if (this.state.verifyStarted && !emailNewPending) {
if (emailNewErrorMessage) {
notify({ message: String(emailNewErrorMessage), displayType: ['toast']});
this.setState({ verifyStarted: false });
} else {
notify({
message: 'Please follow the instructions in the email sent to your address to continue.',
displayType: ['toast']
});
}
}
}
handleChangeText = (text) => {
// save the value to the state email
this.setState({ email: text });
AsyncStorage.setItem(Constants.KEY_FIRST_RUN_EMAIL, text);
}
onSendVerificationPressed = () => {
if (this.state.verifyStarted) {
return;
}
const { addUserEmail, notify } = this.props;
const { email } = this.state;
if (!email || email.trim().length === 0 || email.indexOf('@') === -1) {
return notify({
message: 'Please provide a valid email address to continue.',
displayType: ['toast'],
});
}
this.setState({ verifyStarted: true });
addUserEmail(email);
}
render() {
const { emailNewPending } = this.props;
return (
<View style={rewardStyle.subcard}>
<Text style={rewardStyle.subtitle}>Pending action: Verify Email</Text>
<Text style={rewardStyle.subcardText}>Please provide an email address to verify. If you received a link previously, please follow the instructions in the email to complete verification.</Text>
<TextInput style={rewardStyle.subcardTextInput}
placeholder="you@example.com"
underlineColorAndroid="transparent"
value={this.state.email}
onChangeText={text => this.handleChangeText(text)} />
{!this.state.verifyStarted && <Button style={rewardStyle.actionButton}
text={"Send Verification Email"}
onPress={this.onSendVerificationPressed} />}
{this.state.verifyStarted && emailNewPending && <ActivityIndicator size={"small"} color={Colors.LbryGreen} />}
</View>
);
}
};
export default EmailRewardSubcard;

View file

@ -69,7 +69,7 @@ class FileItem extends React.PureComponent {
navigation.navigate({ routeName: 'File', key: channelUri, params: { uri: channelUri }}); navigation.navigate({ routeName: 'File', key: channelUri, params: { uri: channelUri }});
}} />} }} />}
</TouchableOpacity> </TouchableOpacity>
{obscureNsfw && <NsfwOverlay onPress={() => navigation.navigate('Settings')} />} {obscureNsfw && <NsfwOverlay onPress={() => navigation.navigate({ routeName: 'Settings', key: 'settingsPage' })} />}
</View> </View>
); );
} }

View file

@ -54,9 +54,7 @@ export default class Link extends React.PureComponent {
} }
return ( return (
<Text style={styles} onPress={onPress ? onPress : this.handlePress}> <Text style={styles} onPress={onPress ? onPress : this.handlePress}>{text}</Text>
{text}
</Text>
); );
} }
}; };

View file

@ -0,0 +1,29 @@
import { connect } from 'react-redux';
import { doNotify } from 'lbry-redux';
import {
doClaimRewardType,
doClaimRewardClearError,
makeSelectClaimRewardError,
makeSelectIsRewardClaimPending,
} from 'lbryinc';
import RewardCard from './view';
const makeSelect = () => {
const selectIsPending = makeSelectIsRewardClaimPending();
const selectError = makeSelectClaimRewardError();
const select = (state, props) => ({
errorMessage: selectError(state, props),
isPending: selectIsPending(state, props),
});
return select;
};
const perform = dispatch => ({
claimReward: reward => dispatch(doClaimRewardType(reward.reward_type, true)),
clearError: reward => dispatch(doClaimRewardClearError(reward)),
notify: data => dispatch(doNotify(data))
});
export default connect(makeSelect, perform)(RewardCard);

View file

@ -0,0 +1,95 @@
// @flow
import React from 'react';
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
import Colors from '../../styles/colors';
import Icon from 'react-native-vector-icons/FontAwesome5';
import Link from '../link';
import rewardStyle from '../../styles/reward';
type Props = {
canClaim: bool,
onClaimPress: object,
reward: {
id: string,
reward_title: string,
reward_amount: number,
transaction_id: string,
created_at: string,
reward_description: string,
reward_type: string,
},
};
class RewardCard extends React.PureComponent<Props> {
state = {
claimStarted: false
};
componentWillReceiveProps(nextProps) {
const { errorMessage, isPending } = nextProps;
const { clearError, notify, reward } = this.props;
if (this.state.claimStarted && !isPending) {
if (errorMessage && errorMessage.trim().length > 0) {
notify({ message: errorMessage, displayType: ['toast'] });
clearError(reward);
} else {
notify({ message: 'Reward successfully claimed!', displayType: ['toast'] });
}
this.setState({ claimStarted: false });
}
}
onClaimPress = () => {
const {
canClaim,
claimReward,
notify,
reward
} = this.props;
if (!canClaim) {
notify({ message: 'Unfortunately, you are not eligible to claim this reward at this time.', displayType: ['toast'] });
return;
}
this.setState({ claimStarted: true }, () => {
claimReward(reward);
});
}
render() {
const { canClaim, isPending, onClaimPress, reward } = this.props;
const claimed = !!reward.transaction_id;
return (
<View style={[rewardStyle.rewardCard, rewardStyle.row]}>
<View style={rewardStyle.leftCol}>
{!isPending && <TouchableOpacity onPress={() => {
if (!claimed) {
this.onClaimPress();
}
}}>
<Icon name={claimed ? "check-circle" : "circle"}
style={claimed ? rewardStyle.claimed : (canClaim ? rewardStyle.unclaimed : rewardStyle.disabled)}
size={20} />
</TouchableOpacity>}
{isPending && <ActivityIndicator size="small" color={Colors.LbryGreen} />}
</View>
<View style={rewardStyle.midCol}>
<Text style={rewardStyle.rewardTitle}>{reward.reward_title}</Text>
<Text style={rewardStyle.rewardDescription}>{reward.reward_description}</Text>
{claimed && <Link style={rewardStyle.link}
href={`https://explorer.lbry.io/tx/${reward.transaction_id}`}
text={reward.transaction_id.substring(0, 7)}
error={'The transaction URL could not be opened'} />}
</View>
<View style={rewardStyle.rightCol}>
<Text style={rewardStyle.rewardAmount}>{reward.reward_amount}</Text>
<Text style={rewardStyle.rewardCurrency}>LBC</Text>
</View>
</View>
);
}
};
export default RewardCard;

View file

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import { doRewardList, selectUnclaimedRewardValue, selectFetchingRewards, selectUser } from 'lbryinc';
import RewardSummary from './view';
const select = state => ({
unclaimedRewardAmount: selectUnclaimedRewardValue(state),
fetching: selectFetchingRewards(state),
user: selectUser(state)
});
const perform = dispatch => ({
fetchRewards: () => dispatch(doRewardList()),
});
export default connect(select, perform)(RewardSummary);

View file

@ -0,0 +1,55 @@
import React from 'react';
import { NativeModules, Text, TouchableOpacity } from 'react-native';
import rewardStyle from '../../styles/reward';
class RewardSummary extends React.Component {
state = {
actionsLeft: 0
};
componentDidMount() {
this.props.fetchRewards();
const { user } = this.props;
let actionsLeft = 0;
if (!user || !user.has_verified_email) {
actionsLeft++;
}
this.setState({ actionsLeft }, () => {
if (NativeModules.UtilityModule) {
NativeModules.UtilityModule.canAcquireDeviceId().then(canAcquire => {
if (!canAcquire) {
this.setState({ actionsLeft: this.state.actionsLeft + 1 });
return;
}
}).catch(err => {
this.setState({ actionsLeft: this.state.actionsLeft + 1 });
});
} else {
// unable to retrieve device ID because the native module is not present.
this.setState({ actionsLeft: this.state.actionsLeft + 1 });
}
});
}
render() {
const { fetching, navigation, unclaimedRewardAmount, user } = this.props;
if (this.state.actionsLeft === 0 || unclaimedRewardAmount === 0) {
return null;
}
return (
<TouchableOpacity style={rewardStyle.summaryContainer} onPress={() => {
navigation.navigate('Rewards');
}}>
<Text style={rewardStyle.summaryText}>
You have {unclaimedRewardAmount} LBC in unclaimed rewards. You have {this.state.actionsLeft} action{this.state.actionsLeft === 1 ? '' : 's'} left to claim your first reward. Tap here to continue.
</Text>
</TouchableOpacity>
);
}
}
export default RewardSummary;

View file

@ -51,7 +51,7 @@ class SearchResultItem extends React.PureComponent {
{!isResolvingUri && channel && <Text style={searchStyle.publisher}>{channel}</Text>} {!isResolvingUri && channel && <Text style={searchStyle.publisher}>{channel}</Text>}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
{obscureNsfw && <NsfwOverlay onPress={() => navigation.navigate('Settings')} />} {obscureNsfw && <NsfwOverlay onPress={() => navigation.navigate({ routeName: 'Settings', key: 'settingsPage' })} />}
</View> </View>
); );
} }

View file

@ -17,7 +17,7 @@ import {
searchReducer, searchReducer,
walletReducer walletReducer
} from 'lbry-redux'; } from 'lbry-redux';
import { authReducer, userReducer } from 'lbryinc'; import { authReducer, rewardsReducer, userReducer } from 'lbryinc';
import { createStore, applyMiddleware, compose, combineReducers } from 'redux'; import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
import { createLogger } from 'redux-logger'; import { createLogger } from 'redux-logger';
import { StackNavigator, addNavigationHelpers } from 'react-navigation'; import { StackNavigator, addNavigationHelpers } from 'react-navigation';
@ -73,12 +73,13 @@ const reducers = combineReducers({
claims: claimsReducer, claims: claimsReducer,
costInfo: costInfoReducer, costInfo: costInfoReducer,
fileInfo: fileInfoReducer, fileInfo: fileInfoReducer,
notifications: notificationsReducer,
search: searchReducer,
wallet: walletReducer,
nav: navigatorReducer, nav: navigatorReducer,
notifications: notificationsReducer,
rewards: rewardsReducer,
settings: settingsReducer, settings: settingsReducer,
user: userReducer search: searchReducer,
user: userReducer,
wallet: walletReducer
}); });
const bulkThunk = createBulkThunkMiddleware(); const bulkThunk = createBulkThunkMiddleware();
@ -120,6 +121,9 @@ persistStore(store, persistOptions, err => {
} }
}); });
// TODO: Find i18n module that is compatible with react-native
global.__ = (str) => str;
class LBRYApp extends React.Component { class LBRYApp extends React.Component {
render() { render() {
return ( return (

View file

@ -11,9 +11,10 @@ import {
import { normalizeURI } from 'lbry-redux'; import { normalizeURI } from 'lbry-redux';
import moment from 'moment'; import moment from 'moment';
import Colors from '../../styles/colors'; import Colors from '../../styles/colors';
import FileItem from '../../component/fileItem';
import discoverStyle from '../../styles/discover'; import discoverStyle from '../../styles/discover';
import FloatingWalletBalance from '../../component/floatingWalletBalance'; import FloatingWalletBalance from '../../component/floatingWalletBalance';
import FileItem from '../../component/fileItem';
import RewardSummary from '../../component/rewardSummary';
import UriBar from '../../component/uriBar'; import UriBar from '../../component/uriBar';
class DiscoverPage extends React.PureComponent { class DiscoverPage extends React.PureComponent {
@ -50,6 +51,7 @@ class DiscoverPage extends React.PureComponent {
return ( return (
<View style={discoverStyle.container}> <View style={discoverStyle.container}>
<RewardSummary navigation={navigation} />
{!hasContent && fetchingFeaturedUris && ( {!hasContent && fetchingFeaturedUris && (
<View style={discoverStyle.busyContainer}> <View style={discoverStyle.busyContainer}>
<ActivityIndicator size="large" color={Colors.LbryGreen} /> <ActivityIndicator size="large" color={Colors.LbryGreen} />

View file

@ -19,6 +19,7 @@ class EmailCollectPage extends React.PureComponent {
email: null, email: null,
authenticationStarted: false, authenticationStarted: false,
authenticationFailed: false, authenticationFailed: false,
placeholder: 'you@example.com',
statusTries: 0 statusTries: 0
}; };
@ -63,12 +64,16 @@ class EmailCollectPage extends React.PureComponent {
handleChangeText = (text) => { handleChangeText = (text) => {
// save the value to the state email // save the value to the state email
const { onEmailChanged } = this.props;
this.setState({ email: text }); this.setState({ email: text });
if (onEmailChanged) {
onEmailChanged(text);
}
AsyncStorage.setItem(Constants.KEY_FIRST_RUN_EMAIL, text); AsyncStorage.setItem(Constants.KEY_FIRST_RUN_EMAIL, text);
} }
render() { render() {
const { authenticating, authToken, onEmailViewLayout, emailToVerify } = this.props; const { authenticating, authToken, onEmailChanged, onEmailViewLayout, emailToVerify } = this.props;
let content; let content;
if (this.state.authenticationFailed) { if (this.state.authenticationFailed) {
@ -92,10 +97,20 @@ class EmailCollectPage extends React.PureComponent {
<Text style={firstRunStyle.paragraph}>You can earn LBRY Credits (LBC) rewards by completing various tasks in the app.</Text> <Text style={firstRunStyle.paragraph}>You can earn LBRY Credits (LBC) rewards by completing various tasks in the app.</Text>
<Text style={firstRunStyle.paragraph}>Please provide a valid email address below to be able to claim your rewards.</Text> <Text style={firstRunStyle.paragraph}>Please provide a valid email address below to be able to claim your rewards.</Text>
<TextInput style={firstRunStyle.emailInput} <TextInput style={firstRunStyle.emailInput}
placeholder="you@example.com" placeholder={this.state.placeholder}
underlineColorAndroid="transparent" underlineColorAndroid="transparent"
value={this.state.email} value={this.state.email}
onChangeText={text => this.handleChangeText(text)} onChangeText={text => this.handleChangeText(text)}
onFocus={() => {
if (!this.state.email || this.state.email.length === 0) {
this.setState({ placeholder: '' });
}
}}
onBlur={() => {
if (!this.state.email || this.state.email.length === 0) {
this.setState({ placeholder: 'you@example.com' });
}
}}
/> />
<Text style={firstRunStyle.infoParagraph}>This information is disclosed only to LBRY, Inc. and not to the LBRY network. It is only required to earn LBRY rewards and may be used to sync usage data across devices.</Text> <Text style={firstRunStyle.infoParagraph}>This information is disclosed only to LBRY, Inc. and not to the LBRY network. It is only required to earn LBRY rewards and may be used to sync usage data across devices.</Text>
</View> </View>

View file

@ -27,6 +27,7 @@ class FirstRunScreen extends React.PureComponent {
emailSubmitted: false, emailSubmitted: false,
isFirstRun: false, isFirstRun: false,
launchUrl: null, launchUrl: null,
showSkip: false,
showBottomContainer: true showBottomContainer: true
}; };
@ -97,9 +98,21 @@ class FirstRunScreen extends React.PureComponent {
handleEmailCollectPageContinue() { handleEmailCollectPageContinue() {
const { notify, addUserEmail } = this.props; const { notify, addUserEmail } = this.props;
// validate the email const pageIndex = FirstRunScreen.pages.indexOf(this.state.currentPage);
AsyncStorage.getItem(Constants.KEY_FIRST_RUN_EMAIL).then(email => { AsyncStorage.getItem(Constants.KEY_FIRST_RUN_EMAIL).then(email => {
if (!email || email.trim().length === 0 || email.indexOf('@') === -1) { if (!email || email.trim().length === 0) {
// no email provided. Skip.
if (this.state.currentPage === 'email-collect' && pageIndex === (FirstRunScreen.pages.length - 1)) {
this.closeFinalPage();
} else {
this.showNextPage();
}
return;
}
// validate the email
if (email.indexOf('@') === -1) {
return notify({ return notify({
message: 'Please provide a valid email address to continue.', message: 'Please provide a valid email address to continue.',
displayType: ['toast'], displayType: ['toast'],
@ -131,6 +144,14 @@ class FirstRunScreen extends React.PureComponent {
this.launchSplashScreen(); this.launchSplashScreen();
} }
onEmailChanged = (email) => {
if ('email-collect' == this.state.currentPage) {
this.setState({ showSkip: (!email || email.trim().length === 0) });
} else {
this.setState({ showSkip: false });
}
}
render() { render() {
const { const {
authenticating, authenticating,
@ -149,7 +170,8 @@ class FirstRunScreen extends React.PureComponent {
page = (<EmailCollectPage authenticating={authenticating} page = (<EmailCollectPage authenticating={authenticating}
authToken={authToken} authToken={authToken}
generateAuthToken={generateAuthToken} generateAuthToken={generateAuthToken}
onEmailViewLayout={() => this.setState({ showBottomContainer: true })} />); onEmailChanged={this.onEmailChanged}
onEmailViewLayout={() => this.setState({ showBottomContainer: true, showSkip: true })} />);
} }
return ( return (
@ -162,7 +184,7 @@ class FirstRunScreen extends React.PureComponent {
{!emailNewPending && {!emailNewPending &&
<TouchableOpacity style={firstRunStyle.button} onPress={this.handleContinuePressed}> <TouchableOpacity style={firstRunStyle.button} onPress={this.handleContinuePressed}>
<Text style={firstRunStyle.buttonText}>Continue</Text> <Text style={firstRunStyle.buttonText}>{this.state.showSkip ? 'Skip': 'Continue'}</Text>
</TouchableOpacity>} </TouchableOpacity>}
</View>} </View>}
</View> </View>

View file

@ -0,0 +1,28 @@
import { connect } from 'react-redux';
import {
doRewardList,
selectEmailVerifyErrorMessage,
selectEmailVerifyIsPending,
selectFetchingRewards,
selectUnclaimedRewards,
selectClaimedRewards,
selectUser,
} from 'lbryinc';
import { doNotify } from 'lbry-redux';
import RewardsPage from './view';
const select = state => ({
emailVerifyErrorMessage: selectEmailVerifyErrorMessage(state),
emailVerifyPending: selectEmailVerifyIsPending(state),
fetching: selectFetchingRewards(state),
rewards: selectUnclaimedRewards(state),
claimed: selectClaimedRewards(state),
user: selectUser(state),
});
const perform = dispatch => ({
fetchRewards: () => dispatch(doRewardList()),
notify: data => dispatch(doNotify(data)),
});
export default connect(select, perform)(RewardsPage);

View file

@ -0,0 +1,172 @@
import React from 'react';
import { Lbry } from 'lbry-redux';
import {
DeviceEventEmitter,
ActivityIndicator,
NativeModules,
ScrollView,
Text,
View
} from 'react-native';
import { doInstallNew } from 'lbryinc';
import Colors from '../../styles/colors';
import Link from '../../component/link';
import DeviceIdRewardSubcard from '../../component/deviceIdRewardSubcard';
import EmailRewardSubcard from '../../component/emailRewardSubcard';
import PageHeader from '../../component/pageHeader';
import RewardCard from '../../component/rewardCard';
import rewardStyle from '../../styles/reward';
class RewardsPage extends React.PureComponent {
state = {
canAcquireDeviceId: false,
isEmailVerified: false,
isRewardApproved: false,
verifyRequestStarted: false,
};
componentDidMount() {
DeviceEventEmitter.addListener('onPhoneStatePermissionGranted', this.phoneStatePermissionGranted);
this.props.fetchRewards();
const { user } = this.props;
this.setState({
isEmailVerified: (user && user.primary_email && user.has_verified_email),
isRewardApproved: (user && user.is_reward_approved)
});
if (NativeModules.UtilityModule) {
const util = NativeModules.UtilityModule;
util.canAcquireDeviceId().then(canAcquireDeviceId => {
this.setState({ canAcquireDeviceId });
});
}
}
componentWillUnmount() {
DeviceEventEmitter.removeListener('onPhoneStatePermissionGranted', this.phoneStatePermissionGranted);
}
componentWillReceiveProps(nextProps) {
const { emailVerifyErrorMessage, emailVerifyPending } = nextProps;
if (emailVerifyPending) {
this.setState({ verifyRequestStarted: true });
}
if (this.state.verifyRequestStarted && !emailVerifyPending) {
const { user } = nextProps;
this.setState({ verifyRequestStarted: false });
if (!emailVerifyErrorMessage) {
this.setState({
isEmailVerified: true,
isRewardApproved: (user && user.is_reward_approved)
});
}
}
}
renderVerification() {
if (!this.state.isRewardApproved) {
return (
<View style={[rewardStyle.card, rewardStyle.verification]}>
<Text style={rewardStyle.title}>Humans Only</Text>
<Text style={rewardStyle.text}>Rewards are for human beings only. You'll have to prove you're one of us before you can claim any rewards.</Text>
{!this.state.canAcquireDeviceId && <DeviceIdRewardSubcard />}
{!this.state.isEmailVerified && <EmailRewardSubcard />}
</View>
);
}
return null;
}
phoneStatePermissionGranted = () => {
const { install, notify } = this.props;
if (NativeModules.UtilityModule) {
const util = NativeModules.UtilityModule;
// Double-check just to be sure
util.canAcquireDeviceId().then(canAcquireDeviceId => {
this.setState({ canAcquireDeviceId });
if (canAcquireDeviceId) {
util.getDeviceId(false).then(deviceId => {
NativeModules.VersionInfo.getAppVersion().then(appVersion => {
doInstallNew(`android-${appVersion}`, deviceId);
});
}).catch((error) => {
notify({ message: error, displayType: ['toast'] });
this.setState({ canAcquireDeviceId: false });
});
}
});
}
}
renderUnclaimedRewards() {
const { claimed, fetching, rewards, user } = this.props;
if (fetching) {
return (
<View style={rewardStyle.busyContainer}>
<ActivityIndicator size="large" color={Colors.LbryGreen} />
<Text style={rewardStyle.infoText}>Fetching rewards...</Text>
</View>
);
} else if (user === null) {
return (
<View style={rewardStyle.busyContainer}>
<Text style={rewardStyle.infoText}>This app is unable to earn rewards due to an authentication failure.</Text>
</View>
);
} else if (!rewards || rewards.length <= 0) {
return (
<View style={rewardStyle.busyContainer}>
<Text style={rewardStyle.infoText}>
{(claimed && claimed.length) ? "You have claimed all available rewards! We're regularly adding more so be sure to check back later." :
"There are no rewards available at this time, please check back later."}
</Text>
</View>
);
}
const isNotEligible = !user || !user.primary_email || !user.has_verified_email || !user.is_reward_approved;
return (
<View>
{rewards.map(reward => <RewardCard key={reward.reward_type}
canClaim={!isNotEligible}
reward={reward}
reward_type={reward.reward_type} />)}
</View>
);
}
renderClaimedRewards() {
const { claimed } = this.props;
if (claimed && claimed.length) {
return (
<View>
{claimed.map(reward => <RewardCard key={reward.reward_type} reward={reward} />)}
</View>
);
}
}
render() {
const { user } = this.props;
return (
<View style={rewardStyle.container}>
{this.renderVerification()}
<View style={rewardStyle.rewardsContainer}>
<ScrollView style={rewardStyle.scrollContainer} contentContainerStyle={rewardStyle.scrollContentContainer}>
{this.renderUnclaimedRewards()}
{this.renderClaimedRewards()}
</ScrollView>
</View>
</View>
);
}
}
export default RewardsPage;

View file

@ -17,13 +17,13 @@ const select = state => ({
}); });
const perform = dispatch => ({ const perform = dispatch => ({
authenticate: (appVersion, deviceId) => dispatch(doAuthenticate(appVersion, deviceId)), authenticate: (appVersion, deviceId) => dispatch(doAuthenticate(appVersion, deviceId)),
deleteCompleteBlobs: () => dispatch(doDeleteCompleteBlobs()), deleteCompleteBlobs: () => dispatch(doDeleteCompleteBlobs()),
balanceSubscribe: () => dispatch(doBalanceSubscribe()), balanceSubscribe: () => dispatch(doBalanceSubscribe()),
notify: data => dispatch(doNotify(data)), notify: data => dispatch(doNotify(data)),
setEmailToVerify: email => dispatch(doUserEmailToVerify(email)), setEmailToVerify: email => dispatch(doUserEmailToVerify(email)),
verifyUserEmail: (token, recaptcha) => dispatch(doUserEmailVerify(token, recaptcha)), verifyUserEmail: (token, recaptcha) => dispatch(doUserEmailVerify(token, recaptcha)),
verifyUserEmailFailure: error => dispatch(doUserEmailVerifyFailure(error)), verifyUserEmailFailure: error => dispatch(doUserEmailVerifyFailure(error)),
}); });
export default connect(select, perform)(SplashScreen); export default connect(select, perform)(SplashScreen);

View file

@ -148,14 +148,7 @@ class SplashScreen extends React.PureComponent {
balanceSubscribe(); balanceSubscribe();
NativeModules.VersionInfo.getAppVersion().then(appVersion => { NativeModules.VersionInfo.getAppVersion().then(appVersion => {
this.setState({ shouldAuthenticate: true }); this.setState({ shouldAuthenticate: true });
if (NativeModules.UtilityModule) { authenticate(`android-${appVersion}`);
// authenticate with the device ID if the method is available
NativeModules.UtilityModule.getDeviceId().then(deviceId => {
authenticate(`android-${appVersion}`, deviceId);
});
} else {
authenticate(appVersion);
}
}); });
}); });

View file

@ -9,6 +9,7 @@ import {
makeSelectMetadataForUri, makeSelectMetadataForUri,
selectDownloadingByOutpoint, selectDownloadingByOutpoint,
} from 'lbry-redux'; } from 'lbry-redux';
import { Lbryio, doClaimEligiblePurchaseRewards } from 'lbryinc';
import { Alert, NativeModules } from 'react-native'; import { Alert, NativeModules } from 'react-native';
import Constants from '../../constants'; import Constants from '../../constants';
@ -139,11 +140,17 @@ export function doStopDownloadingFile(uri, fileInfo) {
export function doDownloadFile(uri, streamInfo) { export function doDownloadFile(uri, streamInfo) {
return dispatch => { return dispatch => {
dispatch(doStartDownload(uri, streamInfo.outpoint)); const { outpoint, claim_id: claimId } = streamInfo;
dispatch(doStartDownload(uri, outpoint));
//analytics.apiLog(uri, streamInfo.output, streamInfo.claim_id); // log the view
Lbryio.call('file', 'view', {
uri,
outpoint,
claim_id: claimId
}).catch(() => {});
//dispatch(doClaimEligiblePurchaseRewards()); dispatch(doClaimEligiblePurchaseRewards());
}; };
} }

159
app/src/styles/reward.js Normal file
View file

@ -0,0 +1,159 @@
import { StyleSheet } from 'react-native';
import Colors from './colors';
const rewardStyle = StyleSheet.create({
container: {
flex: 1
},
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center'
},
actionButton: {
backgroundColor: Colors.LbryGreen,
alignSelf: 'flex-start',
paddingTop: 9,
paddingBottom: 9,
paddingLeft: 24,
paddingRight: 24
},
busyContainer: {
flex: 1,
marginTop: 32,
marginBottom: 16,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row'
},
rewardsContainer: {
flex: 1
},
scrollContentContainer: {
paddingBottom: 16
},
card: {
backgroundColor: Colors.White,
marginTop: 16,
marginLeft: 16,
marginRight: 16,
padding: 16
},
rewardCard: {
backgroundColor: Colors.White,
marginTop: 16,
marginLeft: 16,
marginRight: 16,
paddingTop: 16,
paddingBottom: 16
},
text: {
fontFamily: 'Metropolis-Regular',
fontSize: 16,
lineHeight: 24
},
infoText: {
fontFamily: 'Metropolis-Regular',
fontSize: 18,
marginLeft: 12
},
title: {
fontFamily: 'Metropolis-Regular',
fontSize: 22,
marginBottom: 6,
color: Colors.LbryGreen
},
subtitle: {
fontFamily: 'Metropolis-Regular',
fontSize: 18,
marginBottom: 6,
color: Colors.LbryGreen
},
subcardText: {
fontFamily: 'Metropolis-Regular',
fontSize: 15,
lineHeight: 20,
marginLeft: 2,
marginRight: 2
},
subcardTextInput: {
fontFamily: 'Metropolis-Regular',
fontSize: 16,
marginTop: 2,
marginBottom: 2
},
bottomMarginSmall: {
marginBottom: 8
},
bottomMarginMedium: {
marginBottom: 16
},
bottomMarginLarge: {
marginBottom: 24
},
link: {
color: Colors.LbryGreen,
fontFamily: 'Metropolis-Regular',
fontSize: 14,
},
leftCol: {
width: '15%',
alignItems: 'center',
paddingLeft: 6
},
midCol: {
width: '65%'
},
rightCol: {
width: '18%',
alignItems: 'center'
},
rewardAmount: {
fontFamily: 'Metropolis-Regular',
fontSize: 26,
textAlign: 'center'
},
rewardCurrency: {
fontFamily: 'Metropolis-Regular'
},
rewardTitle: {
fontFamily: 'Metropolis-Regular',
fontSize: 16,
color: Colors.LbryGreen,
marginBottom: 4,
},
rewardDescription: {
fontFamily: 'Metropolis-Regular',
fontSize: 14,
lineHeight: 18,
marginBottom: 4
},
claimed: {
color: Colors.LbryGreen,
},
disabled: {
color: Colors.LightGrey
},
subcard: {
borderTopColor: Colors.VeryLightGrey,
borderTopWidth: 1,
paddingTop: 16,
paddingLeft: 8,
paddingRight: 8,
marginTop: 16,
marginLeft: -8,
marginRight: -8
},
summaryContainer: {
backgroundColor: Colors.LbryGreen,
padding: 12
},
summaryText: {
color: Colors.White,
fontFamily: 'Metropolis-Regular',
fontSize: 14,
lineHeight: 22
}
});
export default rewardStyle;

View file

@ -128,10 +128,10 @@ const walletStyle = StyleSheet.create({
margin: 16 margin: 16
}, },
warningText: { warningText: {
color: '#ffffff', color: Colors.White,
fontFamily: 'Metropolis-Regular', fontFamily: 'Metropolis-Regular',
fontSize: 16, fontSize: 16,
lineHeight: 26 lineHeight: 24
}, },
understand: { understand: {
marginLeft: 16, marginLeft: 16,

View file

@ -175,9 +175,6 @@ public class MainActivity extends Activity implements DefaultHardwareBackBtnHand
switch (requestCode) { switch (requestCode) {
case STORAGE_PERMISSION_REQ_CODE: case STORAGE_PERMISSION_REQ_CODE:
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Request for the READ_PHONE_STATE permission
checkPhoneStatePermission(this);
if (BuildConfig.DEBUG && !Settings.canDrawOverlays(this)) { if (BuildConfig.DEBUG && !Settings.canDrawOverlays(this)) {
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:" + getPackageName())); Uri.parse("package:" + getPackageName()));
@ -197,8 +194,12 @@ public class MainActivity extends Activity implements DefaultHardwareBackBtnHand
case PHONE_STATE_PERMISSION_REQ_CODE: case PHONE_STATE_PERMISSION_REQ_CODE:
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
// Permission granted // Permission granted. Emit an onPhoneStatePermissionGranted event
acquireDeviceId(this); ReactContext reactContext = mReactInstanceManager.getCurrentReactContext();
if (reactContext != null) {
reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit("onPhoneStatePermissionGranted", null);
}
} else { } else {
// Permission not granted. Simply show a message. // Permission not granted. Simply show a message.
Toast.makeText(this, Toast.makeText(this,
@ -220,7 +221,6 @@ public class MainActivity extends Activity implements DefaultHardwareBackBtnHand
} else { } else {
id = telephonyManager.getDeviceId(); id = telephonyManager.getDeviceId();
} }
} catch (SecurityException ex) { } catch (SecurityException ex) {
// Maybe the permission was not granted? Try to acquire permission // Maybe the permission was not granted? Try to acquire permission
checkPhoneStatePermission(context); checkPhoneStatePermission(context);
@ -317,10 +317,10 @@ public class MainActivity extends Activity implements DefaultHardwareBackBtnHand
super.onNewIntent(intent); super.onNewIntent(intent);
} }
private static void checkPermission(String permission, int requestCode, String rationale, Context context) { private static void checkPermission(String permission, int requestCode, String rationale, Context context, boolean forceRequest) {
if (ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) { if (ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) {
// Should we show an explanation? // Should we show an explanation?
if (ActivityCompat.shouldShowRequestPermissionRationale((Activity) context, permission)) { if (!forceRequest && ActivityCompat.shouldShowRequestPermissionRationale((Activity) context, permission)) {
Toast.makeText(context, rationale, Toast.LENGTH_LONG).show(); Toast.makeText(context, rationale, Toast.LENGTH_LONG).show();
} else { } else {
ActivityCompat.requestPermissions((Activity) context, new String[] { permission }, requestCode); ActivityCompat.requestPermissions((Activity) context, new String[] { permission }, requestCode);
@ -328,13 +328,22 @@ public class MainActivity extends Activity implements DefaultHardwareBackBtnHand
} }
} }
private static void checkPhoneStatePermission(Context context) { private static void checkPermission(String permission, int requestCode, String rationale, Context context) {
checkPermission(permission, requestCode, rationale, context, false);
}
public static boolean hasPermission(String permission, Context context) {
return (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED);
}
public static void checkPhoneStatePermission(Context context) {
// Request read phone state permission // Request read phone state permission
checkPermission(Manifest.permission.READ_PHONE_STATE, checkPermission(Manifest.permission.READ_PHONE_STATE,
PHONE_STATE_PERMISSION_REQ_CODE, PHONE_STATE_PERMISSION_REQ_CODE,
"LBRY requires optional access to be able to identify your device for rewards. " + "LBRY requires optional access to be able to identify your device for rewards. " +
"You cannot claim rewards without this permission.", "You cannot claim rewards without this permission.",
context); context,
true);
} }
private boolean isServiceRunning(Class<?> serviceClass) { private boolean isServiceRunning(Class<?> serviceClass) {

View file

@ -3,6 +3,9 @@ package io.lbry.browser.reactmodules;
import android.app.Activity; import android.app.Activity;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.Manifest;
import android.os.Build;
import android.telephony.TelephonyManager;
import android.view.View; import android.view.View;
import android.view.WindowManager; import android.view.WindowManager;
@ -87,9 +90,88 @@ public class UtilityModule extends ReactContextBaseJavaModule {
} }
@ReactMethod @ReactMethod
public void getDeviceId(final Promise promise) { public void getDeviceId(boolean requestPermission, final Promise promise) {
SharedPreferences sp = context.getSharedPreferences(MainActivity.SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE); if (isEmulator()) {
String deviceId = sp.getString(MainActivity.DEVICE_ID_KEY, null); promise.reject("Rewards cannot be claimed from an emulator nor virtual device.");
promise.resolve(deviceId); return;
}
TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
String id = null;
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
id = telephonyManager.getImei(); // GSM
if (id == null) {
id = telephonyManager.getMeid(); // CDMA
}
} else {
id = telephonyManager.getDeviceId();
}
} catch (SecurityException ex) {
// Maybe the permission was not granted? Try to acquire permission
if (requestPermission) {
requestPhoneStatePermission();
}
} catch (Exception ex) {
// id could not be obtained. Display a warning that rewards cannot be claimed.
promise.reject(ex.getMessage());
}
if (id == null || id.trim().length() == 0) {
promise.reject("Rewards cannot be claimed because your device could not be identified.");
return;
}
promise.resolve(id);
}
@ReactMethod
public void canAcquireDeviceId(final Promise promise) {
if (isEmulator()) {
promise.resolve(false);
}
promise.resolve(MainActivity.hasPermission(Manifest.permission.READ_PHONE_STATE, MainActivity.getActivity()));
}
@ReactMethod
public void requestPhoneStatePermission() {
MainActivity activity = (MainActivity) MainActivity.getActivity();
if (activity != null) {
// Request for the READ_PHONE_STATE permission
MainActivity.checkPhoneStatePermission(activity);
}
}
private static boolean isEmulator() {
String buildModel = Build.MODEL.toLowerCase();
return (// Check FINGERPRINT
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.startsWith("unknown") ||
Build.FINGERPRINT.contains("test-keys") ||
// Check MODEL
buildModel.contains("google_sdk") ||
buildModel.contains("emulator") ||
buildModel.contains("android sdk built for x86") ||
// Check MANUFACTURER
Build.MANUFACTURER.contains("Genymotion") ||
"unknown".equals(Build.MANUFACTURER) ||
// Check HARDWARE
Build.HARDWARE.contains("goldfish") ||
Build.HARDWARE.contains("vbox86") ||
// Check PRODUCT
"google_sdk".equals(Build.PRODUCT) ||
"sdk_google_phone_x86".equals(Build.PRODUCT) ||
"sdk".equals(Build.PRODUCT) ||
"sdk_x86".equals(Build.PRODUCT) ||
"vbox86p".equals(Build.PRODUCT) ||
// Check BRAND and DEVICE
(Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
);
} }
} }