diff --git a/package-lock.json b/package-lock.json index de4aa37..98d19a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7076,8 +7076,8 @@ } }, "lbryinc": { - "version": "github:lbryio/lbryinc#053ca52f4f7f9bf8eb62a3581b183671a475ad1c", - "from": "github:lbryio/lbryinc#053ca52f4f7f9bf8eb62a3581b183671a475ad1c", + "version": "github:lbryio/lbryinc#138a053754ec8e3da8e9bf153d32f527c962f25c", + "from": "github:lbryio/lbryinc#138a053754ec8e3da8e9bf153d32f527c962f25c", "requires": { "reselect": "^3.0.0" } diff --git a/package.json b/package.json index 2192486..02b1403 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@expo/vector-icons": "^8.1.0", "gfycat-style-urls": "^1.0.3", "lbry-redux": "lbryio/lbry-redux#c910cd2b80b165843a81fdf6ce96094429b94ec8", - "lbryinc": "lbryio/lbryinc#053ca52f4f7f9bf8eb62a3581b183671a475ad1c", + "lbryinc": "lbryio/lbryinc#138a053754ec8e3da8e9bf153d32f527c962f25c", "lodash": ">=4.17.11", "merge": ">=1.2.1", "moment": "^2.22.1", diff --git a/src/component/AppNavigator.js b/src/component/AppNavigator.js index b6f64b9..db507e5 100644 --- a/src/component/AppNavigator.js +++ b/src/component/AppNavigator.js @@ -6,6 +6,7 @@ import DownloadsPage from 'page/downloads'; import DrawerContent from 'component/drawerContent'; import FilePage from 'page/file'; import FirstRunScreen from 'page/firstRun'; +import InvitesPage from 'page/invites'; import PublishPage from 'page/publish'; import PublishesPage from 'page/publishes'; import RewardsPage from 'page/rewards'; @@ -210,6 +211,12 @@ const drawer = createDrawerNavigator( drawerIcon: ({ tintColor }) => <Icon name="award" size={drawerIconSize} style={{ color: tintColor }} />, }, }, + Invites: { + screen: InvitesPage, + navigationOptions: { + drawerIcon: ({ tintColor }) => <Icon name="user-friends" size={drawerIconSize} style={{ color: tintColor }} />, + }, + }, Downloads: { screen: DownloadsPage, navigationOptions: { @@ -530,7 +537,7 @@ class AppWithNavigationState extends React.Component { try { verification = JSON.parse(atob(evt.url.substring(15))); } catch (error) { - console.log(error); + // console.log(error); } if (verification.token && verification.recaptcha) { diff --git a/src/component/channelSelector/view.js b/src/component/channelSelector/view.js index d0ec6bb..952447d 100644 --- a/src/component/channelSelector/view.js +++ b/src/component/channelSelector/view.js @@ -36,10 +36,14 @@ export default class ChannelSelector extends React.PureComponent { } componentWillReceiveProps(nextProps) { - const { channels: prevChannels = [], channelName } = this.props; - const { channels = [] } = nextProps; + const { channels: prevChannels = [], channelName: prevChannelName } = this.props; + const { channels = [], channelName } = nextProps; if (channels && channels.length !== prevChannels.length && channelName !== this.state.currentSelectedValue) { + this.setState({ currentSelectedValue: prevChannelName }); + } + + if (channelName !== prevChannelName) { this.setState({ currentSelectedValue: channelName }); } } @@ -189,10 +193,11 @@ export default class ChannelSelector extends React.PureComponent { render() { const channel = this.state.addingChannel ? 'new' : this.props.channel; - const { balance, enabled, fetchingChannels, channels = [] } = this.props; - const pickerItems = [Constants.ITEM_ANONYMOUS, Constants.ITEM_CREATE_A_CHANNEL].concat( - channels ? channels.map(ch => ch.name) : [], - ); + const { balance, enabled, fetchingChannels, channels = [], showAnonymous } = this.props; + const pickerItems = (showAnonymous + ? [Constants.ITEM_ANONYMOUS, Constants.ITEM_CREATE_A_CHANNEL] + : [Constants.ITEM_CREATE_A_CHANNEL] + ).concat(channels ? channels.map(ch => ch.name) : []); const { newChannelName, diff --git a/src/component/drawerContent/view.js b/src/component/drawerContent/view.js index f6b810a..cdc2815 100644 --- a/src/component/drawerContent/view.js +++ b/src/component/drawerContent/view.js @@ -22,6 +22,7 @@ const groupedMenuItems = { Wallet: [ { icon: 'wallet', label: 'Wallet', route: Constants.DRAWER_ROUTE_WALLET }, { icon: 'award', label: 'Rewards', route: Constants.DRAWER_ROUTE_REWARDS }, + { icon: 'user-friends', label: 'Invites', route: Constants.DRAWER_ROUTE_INVITES }, ], Settings: [ { icon: 'cog', label: 'Settings', route: Constants.DRAWER_ROUTE_SETTINGS }, diff --git a/src/constants.js b/src/constants.js index 20bf1d9..7c508d3 100644 --- a/src/constants.js +++ b/src/constants.js @@ -90,6 +90,7 @@ const Constants = { DRAWER_ROUTE_TAG: 'Tag', DRAWER_ROUTE_CHANNEL_CREATOR: 'ChannelCreator', DRAWER_ROUTE_CHANNEL_CREATOR_FORM: 'ChannnelCreatorForm', + DRAWER_ROUTE_INVITES: 'Invites', FULL_ROUTE_NAME_DISCOVER: 'DiscoverStack', FULL_ROUTE_NAME_WALLET: 'WalletStack', @@ -165,6 +166,7 @@ export const DrawerRoutes = [ Constants.DRAWER_ROUTE_SEARCH, Constants.DRAWER_ROUTE_TRANSACTION_HISTORY, Constants.DRAWER_ROUTE_CHANNEL_CREATOR, + Constants.DRAWER_ROUTE_INVITES, ]; // sub-pages for main routes diff --git a/src/page/channelCreator/view.js b/src/page/channelCreator/view.js index 5903bfe..aff437a 100644 --- a/src/page/channelCreator/view.js +++ b/src/page/channelCreator/view.js @@ -931,7 +931,8 @@ export default class ChannelCreator extends React.PureComponent { source={{ uri: thumbnailUrl }} /> )} - {(!!thumbnailUrl || thumbnailUrl.trim().length === 0) && newChannelName.length > 0 && ( + {(!!thumbnailUrl || (!!thumbnailUrl && thumbnailUrl.trim().length === 0)) && + newChannelName.length > 0 && ( <Text style={channelIconStyle.autothumbCharacter}> {newChannelName.substring(0, 1).toUpperCase()} </Text> diff --git a/src/page/invites/index.js b/src/page/invites/index.js new file mode 100644 index 0000000..230107a --- /dev/null +++ b/src/page/invites/index.js @@ -0,0 +1,46 @@ +import { connect } from 'react-redux'; +import { selectMyChannelClaims, selectFetchingMyChannels, doFetchChannelListMine, doToast } from 'lbry-redux'; +import { + selectReferralReward, + selectUserInvitesRemaining, + selectUserInviteNewIsPending, + selectUserInviteNewErrorMessage, + selectUserInviteReferralLink, + selectUserInviteReferralCode, + selectUserInvitees, + selectUserInviteStatusIsPending, + doFetchInviteStatus, + doUserInviteNew, +} from 'lbryinc'; +import { doPushDrawerStack, doPopDrawerStack, doSetPlayerVisible } from 'redux/actions/drawer'; +import { doUpdateChannelFormState, doClearChannelFormState } from 'redux/actions/form'; +import { selectDrawerStack } from 'redux/selectors/drawer'; +import { selectChannelFormState, selectHasChannelFormState } from 'redux/selectors/form'; +import Constants from 'constants'; // eslint-disable-line node/no-deprecated-api +import InvitesPage from './view'; + +const select = state => ({ + channels: selectMyChannelClaims(state), + fetchingChannels: selectFetchingMyChannels(state), + fetchingInvitees: selectUserInviteStatusIsPending(state), + errorMessage: selectUserInviteNewErrorMessage(state), + invitesRemaining: selectUserInvitesRemaining(state), + referralCode: selectUserInviteReferralCode(state), + isPending: selectUserInviteNewIsPending(state), + invitees: selectUserInvitees(state), + referralReward: selectReferralReward(state), +}); + +const perform = dispatch => ({ + fetchChannelListMine: () => dispatch(doFetchChannelListMine()), + fetchInviteStatus: () => dispatch(doFetchInviteStatus()), + inviteNew: email => dispatch(doUserInviteNew(email)), + pushDrawerStack: () => dispatch(doPushDrawerStack(Constants.DRAWER_ROUTE_INVITES)), + setPlayerVisible: () => dispatch(doSetPlayerVisible(false)), + notify: data => dispatch(doToast(data)), +}); + +export default connect( + select, + perform, +)(InvitesPage); diff --git a/src/page/invites/view.js b/src/page/invites/view.js new file mode 100644 index 0000000..efdf761 --- /dev/null +++ b/src/page/invites/view.js @@ -0,0 +1,228 @@ +import React from 'react'; +import { Lbry, parseURI } from 'lbry-redux'; +import { + ActivityIndicator, + Clipboard, + NativeModules, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import Colors from 'styles/colors'; +import Constants from 'constants'; // eslint-disable-line node/no-deprecated-api +import Icon from 'react-native-vector-icons/FontAwesome5'; +import Link from 'component/link'; +import Button from 'component/button'; +import ChannelSelector from 'component/channelSelector'; +import PageHeader from 'component/pageHeader'; +import RewardCard from 'component/rewardCard'; +import RewardEnrolment from 'component/rewardEnrolment'; +import UriBar from 'component/uriBar'; +import invitesStyle from 'styles/invites'; + +class InvitesPage extends React.PureComponent { + state = { + channelName: null, + email: null, + inviteLink: null, + selectedChannel: null, + }; + + componentWillMount() { + const { navigation } = this.props; + // this.didFocusListener = navigation.addListener('didFocus', this.onComponentFocused); + } + + componentWillUnmount() { + if (this.didFocusListener) { + this.didFocusListener.remove(); + } + } + + onComponentFocused = () => { + const { fetchChannelListMine, fetchInviteStatus, pushDrawerStack, navigation, setPlayerVisible, user } = this.props; + + pushDrawerStack(); + setPlayerVisible(); + NativeModules.Firebase.setCurrentScreen('Invites').then(result => { + fetchChannelListMine(); + fetchInviteStatus(); + }); + }; + + componentDidMount() { + this.onComponentFocused(); + } + + handleChannelChange = channelName => { + const { channels = [] } = this.props; + if (channels && channels.length > 0) { + const filtered = channels.filter(c => c.name === channelName); + if (filtered.length > 0) { + const channel = filtered[0]; + this.setState({ channelName, inviteLink: this.getLinkForChannel(channel) }); + } + } + }; + + getLinkForChannel = channel => { + const { claimId, claimName } = parseURI(channel.permanent_url); + return `https://lbry.tv/$/invite/${claimName}:${claimId}`; + }; + + handleInviteEmailChange = text => { + this.setState({ email: text }); + }; + + handleInvitePress = () => { + const { inviteNew, notify } = this.props; + const { email } = this.state; + if (!email || email.indexOf('@') === -1) { + return notify({ + message: __('Please enter a valid email address to send an invite to.'), + isError: true, + }); + } + + inviteNew(email); + }; + + componentWillReceiveProps(nextProps) { + const { isPending: prevPending, notify } = this.props; + const { channels = [], isPending, errorMessage } = nextProps; + const { email } = this.state; + + if (!this.state.channelName && channels && channels.length > 0) { + const firstChannel = channels[0]; + this.setState({ channelName: firstChannel.name, inviteLink: this.getLinkForChannel(firstChannel) }); + } + + if (prevPending && !isPending) { + if (errorMessage && errorMessage.trim().length > 0) { + notify({ message: errorMessage, isError: true }); + } else { + notify({ message: __(`${email} was invited to the LBRY party!`) }); + this.setState({ email: null }); + } + } + } + + handleInviteLinkPress = () => { + const { notify } = this.props; + Clipboard.setString(this.state.inviteLink); + notify({ + message: __('Invite link copied'), + }); + }; + + render() { + const { fetchingInvitees, user, navigation, notify, isPending, invitees } = this.props; + const { email, inviteLink } = this.state; + const hasInvitees = invitees && invitees.length > 0; + + return ( + <View style={invitesStyle.container}> + <UriBar navigation={navigation} /> + + <ScrollView style={invitesStyle.scrollContainer}> + <TouchableOpacity style={invitesStyle.rewardDriverCard} onPress={() => navigation.navigate('Rewards')}> + <Icon name="award" size={16} style={invitesStyle.rewardDriverIcon} /> + <Text style={invitesStyle.rewardDriverText}>{__('Earn rewards for inviting your friends.')}</Text> + </TouchableOpacity> + + <View style={invitesStyle.card}> + <Text style={invitesStyle.title}>{__('Invite Link')}</Text> + <Text style={invitesStyle.text}> + {__('Share this link with friends (or enemies) and get 20 LBC when they join lbry.tv')} + </Text> + + <Text style={invitesStyle.subTitle}>{__('Your invite link')}</Text> + <View style={invitesStyle.row}> + <Text selectable numberOfLines={1} style={invitesStyle.inviteLink} onPress={this.handleInviteLinkPress}> + {this.state.inviteLink} + </Text> + <Button icon={'clipboard'} style={invitesStyle.button} onPress={this.handleInviteLinkPress} /> + </View> + + <Text style={invitesStyle.customizeTitle}>{__('Customize invite link')}</Text> + <ChannelSelector + showAnonymous={false} + channelName={this.state.channelName} + onChannelChange={this.handleChannelChange} + /> + </View> + + <View style={invitesStyle.card}> + <Text style={invitesStyle.title}>{__('Invite by Email')}</Text> + <Text style={invitesStyle.text}> + {__('Invite someone you know by email and earn 20 LBC when they join lbry.tv.')} + </Text> + + <TextInput + style={invitesStyle.emailInput} + editable={!isPending} + value={this.state.email} + onChangeText={this.handleInviteEmailChange} + placeholder={__('imaginary@friend.com')} + underlineColorAndroid={Colors.NextLbryGreen} + /> + <View style={invitesStyle.rightRow}> + {isPending && ( + <ActivityIndicator size={'small'} color={Colors.NextLbryGreen} style={invitesStyle.loading} /> + )} + <Button + disabled={!email || email.indexOf('@') === -1 || isPending} + style={invitesStyle.button} + text={__('Invite')} + onPress={this.handleInvitePress} + /> + </View> + </View> + + <View style={[invitesStyle.card, invitesStyle.lastCard]}> + <View style={invitesStyle.titleRow}> + <Text style={invitesStyle.titleCol}>{__('Invite History')}</Text> + {fetchingInvitees && <ActivityIndicator size={'small'} color={Colors.NextLbryGreen} />} + </View> + + <Text style={invitesStyle.text}> + {__( + 'Earn 20 LBC for inviting a friend, an enemy, a frenemy, or an enefriend. Everyone needs content freedom.', + )} + </Text> + + <View style={invitesStyle.invitees}> + {hasInvitees && ( + <View style={invitesStyle.inviteesHeader}> + <Text style={invitesStyle.emailHeader} numberOfLines={1}> + {__('Email')} + </Text> + <Text style={invitesStyle.rewardHeader} numberOfLines={1}> + {__('Reward')} + </Text> + </View> + )} + {hasInvitees && + invitees.map(invitee => ( + <View key={invitee.email} style={invitesStyle.inviteeItem}> + <Text style={invitesStyle.inviteeEmail} numberOfLines={1}> + {invitee.email} + </Text> + <Text style={invitesStyle.rewardStatus} numberOfLines={1}> + {invitee.invite_reward_claimed && __('Claimed')} + {!invitee.invite_reward_claimed && + (invitee.invite_reward_claimable ? __('Claimable') : __('Unclaimable'))} + </Text> + </View> + ))} + </View> + </View> + </ScrollView> + </View> + ); + } +} + +export default InvitesPage; diff --git a/src/page/rewards/view.js b/src/page/rewards/view.js index cad8b19..c3aee39 100644 --- a/src/page/rewards/view.js +++ b/src/page/rewards/view.js @@ -11,6 +11,10 @@ import RewardEnrolment from 'component/rewardEnrolment'; import UriBar from 'component/uriBar'; import rewardStyle from 'styles/reward'; +const FILTER_ALL = 'all'; +const FILTER_AVAILABLE = 'available'; +const FILTER_CLAIMED = 'claimed'; + class RewardsPage extends React.PureComponent { state = { isEmailVerified: false, @@ -19,6 +23,7 @@ class RewardsPage extends React.PureComponent { verifyRequestStarted: false, revealVerification: true, firstRewardClaimed: false, + currentFilter: FILTER_AVAILABLE, }; scrollView = null; @@ -182,8 +187,13 @@ class RewardsPage extends React.PureComponent { }); }; + setFilter = filter => { + this.setState({ currentFilter: filter }); + }; + render() { const { user, navigation } = this.props; + const { currentFilter } = this.state; return ( <View style={rewardStyle.container}> @@ -197,8 +207,29 @@ class RewardsPage extends React.PureComponent { style={rewardStyle.scrollContainer} contentContainerStyle={rewardStyle.scrollContentContainer} > - {this.renderUnclaimedRewards()} - {this.renderClaimedRewards()} + <View style={rewardStyle.filterHeader}> + <Link + style={[rewardStyle.filterLink, currentFilter === FILTER_ALL ? rewardStyle.activeFilterLink : null]} + text={__('All')} + onPress={() => this.setFilter(FILTER_ALL)} + /> + <Link + style={[ + rewardStyle.filterLink, + currentFilter === FILTER_AVAILABLE ? rewardStyle.activeFilterLink : null, + ]} + text={__('Available')} + onPress={() => this.setFilter(FILTER_AVAILABLE)} + /> + <Link + style={[rewardStyle.filterLink, currentFilter === FILTER_CLAIMED ? rewardStyle.activeFilterLink : null]} + text={__('Claimed')} + onPress={() => this.setFilter(FILTER_CLAIMED)} + /> + </View> + + {(currentFilter === FILTER_AVAILABLE || currentFilter === FILTER_ALL) && this.renderUnclaimedRewards()} + {(currentFilter === FILTER_CLAIMED || currentFilter === FILTER_ALL) && this.renderClaimedRewards()} </ScrollView> )} </View> diff --git a/src/redux/actions/drawer.js b/src/redux/actions/drawer.js index d799ac7..344d820 100644 --- a/src/redux/actions/drawer.js +++ b/src/redux/actions/drawer.js @@ -1,16 +1,26 @@ import Constants from 'constants'; // eslint-disable-line node/no-deprecated-api -export const doPushDrawerStack = (routeName, params) => dispatch => +export const doPushDrawerStack = (routeName, params) => dispatch => { dispatch({ type: Constants.ACTION_PUSH_DRAWER_STACK, data: { routeName, params }, }); -export const doPopDrawerStack = () => dispatch => + if (window.persistor) { + window.persistor.flush(); + } +}; + +export const doPopDrawerStack = () => dispatch => { dispatch({ type: Constants.ACTION_POP_DRAWER_STACK, }); + if (window.persistor) { + window.persistor.flush(); + } +}; + export const doSetPlayerVisible = (visible, uri) => dispatch => dispatch({ type: Constants.ACTION_SET_PLAYER_VISIBLE, diff --git a/src/styles/invites.js b/src/styles/invites.js new file mode 100644 index 0000000..3fa7278 --- /dev/null +++ b/src/styles/invites.js @@ -0,0 +1,150 @@ +import { StyleSheet } from 'react-native'; +import Colors from './colors'; + +const walletStyle = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: Colors.PageBackground, + }, + scrollContainer: { + marginTop: 60, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + button: { + backgroundColor: Colors.LbryGreen, + alignSelf: 'flex-start', + }, + card: { + backgroundColor: Colors.White, + marginTop: 16, + marginLeft: 16, + marginRight: 16, + padding: 16, + }, + title: { + fontFamily: 'Inter-SemiBold', + fontSize: 20, + marginBottom: 8, + }, + titleRow: { + alignItems: 'center', + flexDirection: 'row', + marginBottom: 8, + justifyContent: 'space-between', + }, + titleCol: { + fontFamily: 'Inter-SemiBold', + fontSize: 20, + }, + text: { + fontFamily: 'Inter-Regular', + fontSize: 14, + }, + link: { + color: Colors.LbryGreen, + fontFamily: 'Inter-Regular', + fontSize: 14, + }, + smallText: { + fontFamily: 'Inter-Regular', + fontSize: 12, + }, + rewardDriverCard: { + alignItems: 'center', + backgroundColor: Colors.RewardDriverBlue, + flexDirection: 'row', + padding: 16, + marginLeft: 16, + marginTop: 16, + marginRight: 16, + }, + rewardDriverIcon: { + color: Colors.White, + marginRight: 8, + }, + rewardDriverText: { + fontFamily: 'Inter-Regular', + color: Colors.White, + fontSize: 14, + }, + subTitle: { + fontFamily: 'Inter-Regular', + fontSize: 14, + marginTop: 12, + marginBottom: 4, + }, + customizeTitle: { + fontFamily: 'Inter-Regular', + fontSize: 14, + marginTop: 12, + }, + inviteLink: { + fontFamily: 'Inter-Regular', + borderWidth: 1, + borderRadius: 16, + borderStyle: 'dashed', + borderColor: '#e1e1e1', + backgroundColor: '#f9f9f9', + paddingTop: 8, + paddingLeft: 8, + paddingRight: 8, + paddingBottom: 6, + width: '88%', + }, + emailInput: { + fontFamily: 'Inter-Regular', + fontSize: 14, + }, + rightRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + }, + loading: { + marginRight: 8, + }, + lastCard: { + marginBottom: 16, + }, + invitees: { + marginTop: 8, + }, + inviteesHeader: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + emailHeader: { + fontFamily: 'Inter-SemiBold', + fontSize: 14, + width: '65%', + }, + rewardHeader: { + fontFamily: 'Inter-SemiBold', + fontSize: 14, + width: '35%', + }, + inviteeItem: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + inviteeEmail: { + fontFamily: 'Inter-Regular', + fontSize: 12, + width: '65%', + }, + rewardStatus: { + fontFamily: 'Inter-Regular', + fontSize: 12, + width: '35%', + }, +}); + +export default walletStyle; diff --git a/src/styles/reward.js b/src/styles/reward.js index db01262..f4b1178 100644 --- a/src/styles/reward.js +++ b/src/styles/reward.js @@ -305,6 +305,23 @@ const rewardStyle = StyleSheet.create({ fontSize: 12, lineHeight: 16, }, + filterHeader: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 16, + marginLeft: 16, + marginRight: 16, + padding: 16, + backgroundColor: Colors.White, + }, + filterLink: { + fontFamily: 'Inter-Regular', + fontSize: 14, + marginRight: 24, + }, + activeFilterLink: { + fontFamily: 'Inter-SemiBold', + }, }); export default rewardStyle; diff --git a/src/utils/helper.js b/src/utils/helper.js index 380a7d7..49e2f62 100644 --- a/src/utils/helper.js +++ b/src/utils/helper.js @@ -10,6 +10,7 @@ const specialRouteMap = { about: Constants.DRAWER_ROUTE_ABOUT, allContent: Constants.DRAWER_ROUTE_TRENDING, channels: Constants.DRAWER_ROUTE_CHANNEL_CREATOR, + invites: Constants.DRAWER_ROUTE_INVITES, library: Constants.DRAWER_ROUTE_MY_LBRY, publish: Constants.DRAWER_ROUTE_PUBLISH, publishes: Constants.DRAWER_ROUTE_PUBLISHES,