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": {
"version": "github:lbryio/lbryinc#8e33473daa56ebe80b12509ac5374c5884efef90",
"from": "github:lbryio/lbryinc#authentication-flow",
"version": "github:lbryio/lbryinc#f5d23dc5ee80198bee8e859bb8487c1137f9fbef",
"from": "github:lbryio/lbryinc#rewards",
"requires": {
"lbry-redux": "github:lbryio/lbry-redux#31f7afa8a37f5741dac01fc1ecdf153f3bed95dc",
"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": {
@ -5277,6 +5268,13 @@
"which-module": "^2.0.0",
"y18n": "^3.2.1",
"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="
},
"y18n": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz",
"integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE="
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
"integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w=="
},
"yallist": {
"version": "2.1.2",
@ -6870,6 +6868,13 @@
"which-module": "^2.0.0",
"y18n": "^3.2.1",
"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": {

View file

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

View file

@ -3,8 +3,9 @@ import AboutPage from '../page/about';
import DiscoverPage from '../page/discover';
import FilePage from '../page/file';
import FirstRunScreen from '../page/firstRun';
import SearchPage from '../page/search';
import RewardsPage from '../page/rewards';
import TrendingPage from '../page/trending';
import SearchPage from '../page/search';
import SettingsPage from '../page/settings';
import SplashScreen from '../page/splash';
import TransactionHistoryPage from '../page/transactionHistory';
@ -99,10 +100,21 @@ const walletStack = StackNavigator({
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({
DiscoverStack: { screen: discoverStack },
TrendingStack: { screen: trendingStack },
WalletStack: { screen: walletStack },
Rewards: { screen: rewardsStack },
Settings: { screen: SettingsPage, navigationOptions: { drawerLockMode: 'locked-closed' } },
About: { screen: AboutPage, navigationOptions: { drawerLockMode: 'locked-closed' } }
}, {
@ -189,7 +201,7 @@ class AppWithNavigationState extends React.Component {
if (notification) {
const { displayType, message } = notification;
let currentDisplayType;
if (displayType.length) {
if (displayType && displayType.length) {
for (let i = 0; i < displayType.length; i++) {
const type = displayType[i];
if (AppWithNavigationState.supportedDisplayTypes.indexOf(type) > -1) {
@ -201,6 +213,11 @@ class AppWithNavigationState extends React.Component {
currentDisplayType = displayType;
}
if (!currentDisplayType && message) {
// default to toast if no display type set and there is a message specified
currentDisplayType = 'toast';
}
if ('toast' === currentDisplayType) {
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 }});
}} />}
</TouchableOpacity>
{obscureNsfw && <NsfwOverlay onPress={() => navigation.navigate('Settings')} />}
{obscureNsfw && <NsfwOverlay onPress={() => navigation.navigate({ routeName: 'Settings', key: 'settingsPage' })} />}
</View>
);
}

View file

@ -10,10 +10,10 @@ export default class Link extends React.PureComponent {
}
this.addTappedStyle = this.addTappedStyle.bind(this)
}
handlePress = () => {
const { error, href, navigation, notify } = this.props;
if (navigation && href.startsWith('#')) {
navigation.navigate(href.substring(1));
} else {
@ -32,14 +32,14 @@ export default class Link extends React.PureComponent {
this.setState({ tappedStyle: true });
setTimeout(() => { this.setState({ tappedStyle: false }); }, 2000);
}
render() {
const {
onPress,
style,
text
} = this.props;
let styles = [];
if (style) {
if (style.length) {
@ -52,11 +52,9 @@ export default class Link extends React.PureComponent {
if (this.props.effectOnTap && this.state.tappedStyle) {
styles.push(this.props.effectOnTap);
}
return (
<Text style={styles} onPress={onPress ? onPress : this.handlePress}>
{text}
</Text>
<Text style={styles} onPress={onPress ? onPress : this.handlePress}>{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

@ -18,18 +18,18 @@ class SearchResultItem extends React.PureComponent {
onPress,
navigation
} = this.props;
const uri = normalizeURI(this.props.uri);
const obscureNsfw = this.props.obscureNsfw && metadata && metadata.nsfw;
const title = metadata && metadata.title ? metadata.title : parseURI(uri).contentName;
let name;
let channel;
if (claim) {
name = claim.name;
channel = claim.channel_name;
}
return (
<View style={style}>
<TouchableOpacity style={style} onPress={onPress}>
@ -46,12 +46,12 @@ class SearchResultItem extends React.PureComponent {
<View style={searchStyle.row}>
<ActivityIndicator size={"small"} color={Colors.LbryGreen} />
</View>
</View>)}
</View>)}
{!isResolvingUri && <Text style={searchStyle.title}>{title || name}</Text>}
{!isResolvingUri && channel && <Text style={searchStyle.publisher}>{channel}</Text>}
</View>
</TouchableOpacity>
{obscureNsfw && <NsfwOverlay onPress={() => navigation.navigate('Settings')} />}
{obscureNsfw && <NsfwOverlay onPress={() => navigation.navigate({ routeName: 'Settings', key: 'settingsPage' })} />}
</View>
);
}

View file

@ -17,7 +17,7 @@ import {
searchReducer,
walletReducer
} from 'lbry-redux';
import { authReducer, userReducer } from 'lbryinc';
import { authReducer, rewardsReducer, userReducer } from 'lbryinc';
import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
import { createLogger } from 'redux-logger';
import { StackNavigator, addNavigationHelpers } from 'react-navigation';
@ -73,12 +73,13 @@ const reducers = combineReducers({
claims: claimsReducer,
costInfo: costInfoReducer,
fileInfo: fileInfoReducer,
notifications: notificationsReducer,
search: searchReducer,
wallet: walletReducer,
nav: navigatorReducer,
notifications: notificationsReducer,
rewards: rewardsReducer,
settings: settingsReducer,
user: userReducer
search: searchReducer,
user: userReducer,
wallet: walletReducer
});
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 {
render() {
return (

View file

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

View file

@ -19,6 +19,7 @@ class EmailCollectPage extends React.PureComponent {
email: null,
authenticationStarted: false,
authenticationFailed: false,
placeholder: 'you@example.com',
statusTries: 0
};
@ -63,12 +64,16 @@ class EmailCollectPage extends React.PureComponent {
handleChangeText = (text) => {
// save the value to the state email
const { onEmailChanged } = this.props;
this.setState({ email: text });
if (onEmailChanged) {
onEmailChanged(text);
}
AsyncStorage.setItem(Constants.KEY_FIRST_RUN_EMAIL, text);
}
render() {
const { authenticating, authToken, onEmailViewLayout, emailToVerify } = this.props;
const { authenticating, authToken, onEmailChanged, onEmailViewLayout, emailToVerify } = this.props;
let content;
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}>Please provide a valid email address below to be able to claim your rewards.</Text>
<TextInput style={firstRunStyle.emailInput}
placeholder="you@example.com"
placeholder={this.state.placeholder}
underlineColorAndroid="transparent"
value={this.state.email}
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>
</View>

View file

@ -27,6 +27,7 @@ class FirstRunScreen extends React.PureComponent {
emailSubmitted: false,
isFirstRun: false,
launchUrl: null,
showSkip: false,
showBottomContainer: true
};
@ -97,9 +98,21 @@ class FirstRunScreen extends React.PureComponent {
handleEmailCollectPageContinue() {
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 => {
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({
message: 'Please provide a valid email address to continue.',
displayType: ['toast'],
@ -131,6 +144,14 @@ class FirstRunScreen extends React.PureComponent {
this.launchSplashScreen();
}
onEmailChanged = (email) => {
if ('email-collect' == this.state.currentPage) {
this.setState({ showSkip: (!email || email.trim().length === 0) });
} else {
this.setState({ showSkip: false });
}
}
render() {
const {
authenticating,
@ -149,7 +170,8 @@ class FirstRunScreen extends React.PureComponent {
page = (<EmailCollectPage authenticating={authenticating}
authToken={authToken}
generateAuthToken={generateAuthToken}
onEmailViewLayout={() => this.setState({ showBottomContainer: true })} />);
onEmailChanged={this.onEmailChanged}
onEmailViewLayout={() => this.setState({ showBottomContainer: true, showSkip: true })} />);
}
return (
@ -162,7 +184,7 @@ class FirstRunScreen extends React.PureComponent {
{!emailNewPending &&
<TouchableOpacity style={firstRunStyle.button} onPress={this.handleContinuePressed}>
<Text style={firstRunStyle.buttonText}>Continue</Text>
<Text style={firstRunStyle.buttonText}>{this.state.showSkip ? 'Skip': 'Continue'}</Text>
</TouchableOpacity>}
</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 => ({
authenticate: (appVersion, deviceId) => dispatch(doAuthenticate(appVersion, deviceId)),
deleteCompleteBlobs: () => dispatch(doDeleteCompleteBlobs()),
balanceSubscribe: () => dispatch(doBalanceSubscribe()),
notify: data => dispatch(doNotify(data)),
setEmailToVerify: email => dispatch(doUserEmailToVerify(email)),
verifyUserEmail: (token, recaptcha) => dispatch(doUserEmailVerify(token, recaptcha)),
verifyUserEmailFailure: error => dispatch(doUserEmailVerifyFailure(error)),
authenticate: (appVersion, deviceId) => dispatch(doAuthenticate(appVersion, deviceId)),
deleteCompleteBlobs: () => dispatch(doDeleteCompleteBlobs()),
balanceSubscribe: () => dispatch(doBalanceSubscribe()),
notify: data => dispatch(doNotify(data)),
setEmailToVerify: email => dispatch(doUserEmailToVerify(email)),
verifyUserEmail: (token, recaptcha) => dispatch(doUserEmailVerify(token, recaptcha)),
verifyUserEmailFailure: error => dispatch(doUserEmailVerifyFailure(error)),
});
export default connect(select, perform)(SplashScreen);

View file

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

View file

@ -9,6 +9,7 @@ import {
makeSelectMetadataForUri,
selectDownloadingByOutpoint,
} from 'lbry-redux';
import { Lbryio, doClaimEligiblePurchaseRewards } from 'lbryinc';
import { Alert, NativeModules } from 'react-native';
import Constants from '../../constants';
@ -139,11 +140,17 @@ export function doStopDownloadingFile(uri, fileInfo) {
export function doDownloadFile(uri, streamInfo) {
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
},
warningText: {
color: '#ffffff',
color: Colors.White,
fontFamily: 'Metropolis-Regular',
fontSize: 16,
lineHeight: 26
lineHeight: 24
},
understand: {
marginLeft: 16,

View file

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

View file

@ -3,6 +3,9 @@ package io.lbry.browser.reactmodules;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.Manifest;
import android.os.Build;
import android.telephony.TelephonyManager;
import android.view.View;
import android.view.WindowManager;
@ -87,9 +90,88 @@ public class UtilityModule extends ReactContextBaseJavaModule {
}
@ReactMethod
public void getDeviceId(final Promise promise) {
SharedPreferences sp = context.getSharedPreferences(MainActivity.SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE);
String deviceId = sp.getString(MainActivity.DEVICE_ID_KEY, null);
promise.resolve(deviceId);
public void getDeviceId(boolean requestPermission, final Promise promise) {
if (isEmulator()) {
promise.reject("Rewards cannot be claimed from an emulator nor virtual device.");
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"))
);
}
}