Build a React Native HackerNews app where people are nice – using Google’s language API and MobX

0:00

This week’s app idea comes from Dan Abramov: A Hacker News app where people are nice. We’re going to use HN’s official Firebase API to load stories and comments, Google’s Natural Language API to hide negative comments, MobX for state management, Shoutem UI Toolkit for styling, and react-native-htmlview to render HTML as a tree of native Text components.

Our HackerNews app where people are nice lets you browse stories by category and read their comments. Each comment shows whether it is neutral or positive. Negative comments are hidden. Posting comes later, this is an MVP.

React Native HackerNews app
Hiding negative comments

Click “3 replies”, 1 shows up. Click “1 reply”, 0 show up. That’s Google’s Sentiment Analysis hiding things that are negative. You’re welcome.

To be honest, I was surprised by the amount of negativity on Hacker News. I knew it was bad, but I didn’t think it was that bad until I ran it through Google’s Natural Language API.

Here’s a quick breakdown for a frontpage story, “Using Tesseract OCR with Python”. Somebody shares what they’ve built: 6 negative comments, 6 neutral comments, 8 positive comments.

Comments chart
Comments chart

Let’s build our app. You can see the full code on GitHub.

Learn React Native with a new app tutorial in your inbox every 2 weeks

indicates required



Setup

Before we begin, we’ll need some dependencies and a new React Native project. Remember to use 0.43.3 so we can use Shoutem UI.

react-native init HackerNewsApp --version=react-native@0.43.3
cd HackerNewsApp
npm install --save @shoutem/ui mobx mobx-react firebase lodash prop-types react-native-htmlview
react-native link

That gives you the default app.

We should enable decorators because they make MobX nicer to use. No sweat if you don’t like them, it works without. I think they make our code easier to read though.

npm install --save babel-preset-react-native-stage-0

And edit .babelrc.

// .babelrc
{
"presets": ["react-native", "react-native-stage-0/decorator-support"]
}

To finish setting up our project, we create a src/ directory and change index.ios.js or index.android.js to render our <App> component. This particular app should work unchanged on both iOS and Android.

// index.ios.js

import React, { Component } from 'react';
import {
  AppRegistry
} from 'react-native';

import App from './src';

export default class HackerNewsApp extends Component {
  render() {
    return (
      <App />
    );
  }
}

AppRegistry.registerComponent('HackerNewsApp', () => HackerNewsApp);

And in src/index.js we create an empty App with a MobX store.

// src/index.js

import React, { Component } from 'react';
import { Screen, NavigationBar, DropDownMenu, Divider, View, Text } from '@shoutem/ui';
import { CardStack } from '@shoutem/ui/navigation';
import { useStrict } from 'mobx';
import { Provider, observer } from 'mobx-react/native';

useStrict(true);

import Store from './Store';

@observer
class App extends Component {
    render() {
        return (
            <Provider store={Store}>
                <Screen />
            </Provider>
        )
    }
}

export default App;

We import everything we’ll need because I know the future, set MobX to strict mode so we can’t make accidental state changes, import our Store, and create a blank App component with a Provider that makes our store globally available.

We need a blank MobX store as well.

// src/Store.js

import { action, computed, observable, extendObservable } from 'mobx';
import * as firebase from 'firebase';
import take from 'lodash/take';

class Store {
}

export default new Store();

A MobX store is a JavaScript object. It gains super powers when we add observers, actions, and computed values.

MobX 101

If you’re new to MobX, these are the basics you need to know.

MobX implements the ideas of reactive programming. There are values that are observable and functions that react when those values change. MobX ensures only the minimal possible set of observers is triggered on every change.

So, we have:

@observable – a property whose changes observers subscribe to
@observer – a component whose render() method observes values
@computed – a method whose value can be fully derived from obsevables
@action – a method that changes state, analogous to a Redux reducer
@inject – a decorator that injects global stores into a component’s props

That’s all you need to know. Once your component is an @observer, you never have to worry about what it’s observing. MobX ensures it reacts only to the values that it uses during rendering.

Basic Navigation

Our HackerNews app begins with basic navigation: A dropdown for different story types and a button to go navigate back.

One way to achieve this, is using Shoutem’s NavigationBar and CardStack components. They’re based on ReactNavigationExperimental, which has turned into ReactNavigation in recent months. More on that in my react-navigation tutorial.

The visual part of basic navigation goes in our <App> component.

// src/index.js

@observer
class App extends Component {

    renderDropDown() {
        return (
            <DropDownMenu options={Store.storyTypes.slice()}
                          titleProperty="name"
                          valueProperty="value"
                          selectedOption={Store.selectedStoryOption}
                          onOptionSelected={storyType => Store.pickStoryType(storyType)} />
        );
    }

    renderNavBar({ navigationState }) {
        const route = navigationState.routes[navigationState.index];
        let centerComponent = null;

        if (route.type === 'storylist') {
            centerComponent = this.renderDropDown();
        }else{
            centerComponent = (
                <Text ellipsizeMode="tail" numberOfLines={1}>
                    {Store.items.get(route.id).title}
                </Text>
            );
        }

        return (
            <NavigationBar hasHistory={navigationState.index > 0}
                           navigateBack={() => Store.navigateBack()}
                           centerComponent={centerComponent} />
        );
    }

    renderScene({ scene }) {
        const { route } = scene;

        return <Screen />;
    }

    render() {
        return (
            <Provider store={Store}>
                <CardStack navigationState={Store.navigationState}
                           renderNavBar={this.renderNavBar.bind(this)}
                           renderScene={this.renderScene.bind(this)} />
            </Provider>
        )
    }
}

The code reads bottom to top. We replace the empty <Screen> render with a <CardStack> component. It creates a stackable screens effect with swipey animations when users navigate.

As props it takes:

  • navigationState, an object with an index and a list of routes
  • renderNavBar, a method that renders the navigation bar
  • renderScene, a method that renders scenes based on routes

More on navigationState when we look at our MobX Store. We use Shoutem’s NavBar and DropDown components to render the Navbar and keep renderScene empty for now. We’ll fill it in when we list stories and render individual items.

Our NavBar renders either a story title or a dropdown to switch between different types of stories. The hasHistory prop makes a back button appear when needed.

React Native Hackernews app NavBar
NavBar

MobX store support

The code we just wrote needs some support from our MobX Store to work. We need a list of storyTypes for the dropdown, a way to store current navigationState, and actions to drive transitions.

// src/Store.js

class Store {
    @observable storyTypes = [
        {name: 'Top', value: 'topstories'},
        {name: 'Ask HN', value: 'askstories'},
        {name: 'Show HN', value: 'showstories'},
        {name: 'Jobs', value: 'jobstories'}
    ];

    @observable _navigationState = {
        index: 0,
        routes: [
            {key: 'topstories', type: 'storylist'}
        ]

    };

    @computed get navigationState() {
        return {
            index: this._navigationState.index,
            routes: this._navigationState.routes.slice()
        };
    }

    @computed get currentRoute() {
        return this._navigationState.routes[this._navigationState.index];
    }

    @computed get selectedStoryOption() {
        const { key } = this.currentRoute;

        return this.storyTypes.find(
            ({ name, value }) => value === key
        );
    }

    @action navigateBack() {
        this._navigationState.index -= 1;
        this._navigationState.routes.pop();
    }

    @action pickStoryType({ value }) {
        //this.listenForStories(value);

        this._navigationState.routes.push({
            key: value,
            type: 'storylist'
        });
        this._navigationState.index += 1;
    }
}

Here’s what I like about MobX: You can read this code even if you don’t know how MobX works. The hard stuff happens behind the scenes, you just write the business logic of your app.

We have a list of storyTypes that have a name and a key. We have a _navigationState with an index and a list of routes. Pre-filled with default values.

Then we have some computed values. They derive purely from our store. A navigationState returns a plain JavaScript version of _navigationState without MobX’s additions, and currentRoute and selectedStoryOption tell us some current state. They’re mostly helpers to make our life easier.

Our actions handle state manipulations. navigateBack pops the last navigation state from stack, and pickStoryType pushes a new one onto stack.

When state changes, MobX handles re-rendering behind the scenes.

Listing Stories

We need to change 3 files to render a list of stories: Add loading logic to the store, start initial fetching on App render, and render a ListView with the result.

Store

// src/Store.js

firebase.initializeApp({
    databaseURL: 'https://hacker-news.firebaseio.com'
});

const N_STORIES = 30,
      LANG_API_KEY = 'AIzaSyCSE5mekK1XxfDMQde8bywlaOMIdN5L7ug';

class Store {
    @observable stories = observable.map();
    @observable items = observable.map();
    @observable alreadyListening = observable.map();
   
    // ...
    @action listenForStories(storyType) {
        if (!this.alreadyListening.get(storyType)) {
            this.alreadyListening.set(storyType, true);
            firebase.database()
                    .ref(`v0/${storyType}`)
                    .on('value', snapshot => {
                        const ids = take(snapshot.val(), N_STORIES);

                        this.updateStories(storyType, ids);
                        ids.forEach(id => this.listenToStory(id));
                    })
        }
    }

    @action updateStories(storyType, ids) {
        this.stories.set(storyType, ids);
    }

    @action listenToStory(id) {
        firebase.database()
                .ref(`v0/item/${id}`)
                .on('value', snapshot => {
                    this.updateItem(id, snapshot.val());
                });
    }

    @action updateItem(id, val) {
        val.sentiment = {fetched: false};
        this.items.set(id, val);
    }  
}   

We start by initializing our Firebase API connection and setting two constants. N_STORIES tells us how many stories we want to load, and we’ll use LANG_API_KEY later to talk to Google’s Natural Language Processing API.

You should consider getting your own key. It’s not hard.

In the Store itself, we define 3 observables. stories is a mapping from story type to list of ids, items maps ids to their values HackerNews API values, and alreadyListening is a set of flags that helps us avoid listening to the same stuff multiple times.

Firebase is live and all that. Once we connect, we’re going to keep getting data. Wouldn’t want to overdo it 🙂

Then we have 3 actions:

  1. listenForStories connects to Firebase on a specific story type endpoint. It listens for live updates and adds them to our state via updateItem and listenToStory
  2. updateItem sets a specific item’s value to the API result. Later, we’ll use it for comments as well.
  3. listenToStory connects to Firebase and starts listening for updates to a specific story.

The combination of listenForStories and listenToStory ensures our list of stories re-renders in real time as the frontpage changes. Stories move up and down, upvotes accumulate, and potential title changes happen in real-time.

App

// src/index.js

class App extends Component {
    componentDidMount() {
        Store.listenForStories(Store.currentRoute.key);
    }

    // ..
    
    renderScene({ scene }) {
        const { route } = scene;

        if (route.type === 'storylist') {
            return (
                <Screen style={{paddingTop: 75}}>
                    <StoriesList storyType={route.key} />
                </Screen>
            )
        }
    }
    
    // ..
}

Our <App> component doesn’t need many changes. We make the first listenForStories call in componentDidMount, and render a <StoriesList> component in renderScene.

StoriesList

StoriesList renders a list of stories. Each shows a title, its number of likes, and how long ago it was posted.

HackerNews app StoriesList
StoriesList
// src/StoriesList.js

import React from 'react';
import { observer, inject } from 'mobx-react';
import { View, ListView, Subtitle, Caption, Spinner, Row, Icon, TouchableOpacity } from '@shoutem/ui';
import moment from 'moment';

const Story =
inject('store')(observer(function Story({ store, id }) {
    const item = store.items.get(id);

    if (item) {
        return (
            <TouchableOpacity onPress={() => store.openStory(id)}>
                <Row>
                    <View styleName="vertical">
                        <Subtitle>{item.title}</Subtitle>
                        <View styleName="horizontal space-between">
                            <Caption>
                                <Icon style={{fontSize: 15}} name="like" />
                                {item.score}
                            </Caption>
                            <Caption>
                                {moment.unix(item.time).fromNow()}
                            </Caption>
                        </View>
                    </View>
                    <Icon styleName="disclosure" name="right-arrow" />
                </Row>
             </TouchableOpacity>
        )
    }else{
        return (
            <Row styleName="small">
                <Caption>Loading ... ({id})</Caption>
            </Row>
        )
    }
}));

const StoriesList =
inject('store')(observer(function StoriesList ({ store, storyType }) {
    const stories = store.stories.get(storyType);

    if (stories) {
        return (
            <ListView data={stories.slice()}
                      renderRow={id => <Story id={id} />} />
        )
    }else{
        return <Spinner />
    }
}));

export default StoriesList;

Most of this code deals with rendering a nice looking Story, so I’ll breeze over the description.

We have a StoriesList, which takes our store from React Context via inject, and uses it to get a list of stories. If we have stories to show, it renders a ListView.

One thing to note in this <ListView> call is that we feed it stories.slice(). That’s because arrays in MobX stores are not real arrays, they’re array-like objects filled with MobX wiring. ListView doesn’t know how to handle that, so we turn them into a plain JavaScript array.

According to the creator of MobX, using .slice() is the neatest way of doing that.

The Story component renders an individual row in our list. A title, a thumbsup icon, the number of likes, how long it’s been since the story appeared.

Clicking a story calls an openStory action on our store. It loads comments.

Loading and Filtering Comments

Similarly to rendering a list of stories, rendering comments involves 3 steps:

  1. Add business logic to the store
  2. Add rendering to <App>
  3. Define comment components
// src/Store.js

class Store {
    // ...
    @action openStory(id) {
        this._navigationState.routes.push({
            key: String(id),
            id: id,
            type: 'story'
        });
        this._navigationState.index += 1;

        // recursively walk through kids
        this.loadItem(id);
    }

    @action loadItem(id) {
        firebase.database()
                .ref(`v0/item/${id}`)
                .once('value', snapshot => {
                    const val = snapshot.val();

                    this.updateItem(id, val);
                    this.analyzeSentiment(id);

                    if (val.kids) {
                        val.kids.forEach(id => this.loadItem(id));
                    }
                });
    }

    @action analyzeSentiment(id) {
        const { text, sentiment } = this.items.get(id);

        if (!sentiment.fetched) {
            fetch(`https://language.googleapis.com/v1/documents:analyzeSentiment?key=${LANG_API_KEY}`,
                  {
                      method: "POST",
                      headers: {
                          'Accept': 'application/json',
                          'Content-Type': 'application/json'
                      },
                      body: JSON.stringify({
                          "encodingType": "UTF8",
                          "document": {
                              "type": "PLAIN_TEXT",
                              "content": text
                          }
                      })
                  }).then(res => res.json())
                    .then(json => {
                        this.updateSentiment(id, json);
                    });
        }
    }

    @action updateSentiment(id, { documentSentiment }) {
        if (documentSentiment) {
            extendObservable(this.items.get(id).sentiment, documentSentiment);
            this.items.get(id).sentiment.fetched = true;
        }
    }
}

Comments don’t need any new state because we’re reusing the @observable items mechanism. HackerNews API puts comments and stories in the same id namespace and offers the same API endpoint to fetch individual items.

We mimic that approach.

When the openStory action is called, we push a new state to navigationState. This seamlessly updates our NavigationBar and CardStack from earlier.

Then we use loadItem to load an individual item. It connects to Firebase and fetches a story or comment, once. Stories can have a lot of comments and listening for live updates would keep too many open connections. Comments rarely change anyway.

When a comment is fetched, we use updateItem to save it in state, call analyzeSentiment, and walk through its list of kids to recursively load any replies.

analyzeSentiment uses a fetch call to Google Natural Language Processing to see if a comment is positive or negative. Scores range from -1.0 for most negative, to 1.0 for most positive. We’ll avoid rendering anything below 0.

Yes, that means that if a negative comment has many positive replies, those replies will be unreachable. Alas.

updateSentiment does what its name suggests. It updates the sentiment value for an existing item in our store.

Showing Comments

React Native HackerNews app comments
Showing comments

To render comments, and individual stories in general, we’re creating an <HNItem> component. It looks at the type of item it’s rendering and does the right thing. Stories are a <ListView> of children (comments) with a nice header, and comments are some text plus a <ListView> of children.

The fact that rendering nested ListViews with wanton disregard works without a hiccup, especially scrolling, is a nice surprise. Good job React Native!

App

But first! We add a <HNItem> render to our renderScene function from earlier. Its recursive nature takes care of the rest.

// src/index.js

class App extends Component {
    // ...
    renderScene({ scene }) {
        const { route } = scene;

        if (route.type === 'storylist') {
            return (
                <Screen style={{paddingTop: 75}}>
                    <StoriesList storyType={route.key} />
                </Screen>
            )
        }else{
            return (
                <Screen style={{paddingTop: 75}}>
                    <HNItem id={route.id} />
                </Screen>
            );
        }
    }

    // ..
}

HNItem

HNItem is a general component that renders any HackerNews item object. When it’s a story, it has a title with some metadata followed by a list of comments. When it’s a comment, it has text with metadata followed by a list of replies.

It looks like this:

// src/HNItem.js

import React, { Component } from 'react';
import { observer, inject } from 'mobx-react';
import { View, ListView, Text, Spinner } from '@shoutem/ui';

import Comment from './Comment';
import Story from './Story';


const Children = observer(({ item, renderHeader = () => null }) => {
    if (item.kids) {
        return (
            <View style={{paddingLeft: 12}}>
                <ListView data={item.kids.slice()}
                          renderRow={id => <HNItem id={id} />}
                          renderHeader={renderHeader} />
            </View>
        )
    }else{
        return <Spinner />;
    }
});


const HNItem =
inject('store')(observer(function HNItem({ store, id }) {
    const item = store.items.get(id);

    if (!item || item.type === 'comment' && !item.sentiment.fetched) {
        return (<Spinner />);
    }

    if (item.type === 'story') {
        return (<Story item={item} />);
    }else if (item.type === 'comment') {
        if (item.sentiment.score < 0) {
            return null;
        }else{
            return (<Comment item={item} />);
        }
    }
}));

export default HNItem;
export { Children };

Essentially an if with different render options.

We define a <Children> component in the same file because both Story and Comment use it to render their children. You can think of it as a helper component that renders a spinner until replies are loaded.

Story

The Story component renders a single story and its comments. We use ListView’s header feature to render a title and some metadata.

// src/Story.js

import React from 'react';
import { Linking } from 'react-native';
import { observer } from 'mobx-react';
import { TouchableOpacity, Heading, View, Subtitle, Icon, Text } from '@shoutem/ui';
import moment from 'moment';
import HTMLView from 'react-native-htmlview';

import { Children } from './HNItem';

const Story = observer(({ item }) => (
    <View style={{paddingLeft: 14, paddingRight: 14}}>
        <Children item={item}
                  renderHeader={() => <StoryHeader item={item} />}/>
    </View>
));

const StoryHeader = observer(({ item }) => (
    <View>
        <TouchableOpacity onPress={() => Linking.openURL(item.url)}>
            <Heading>{item.title}</Heading>
        </TouchableOpacity>

        <View styleName="horizontal space-between" style={{paddingTop: 5}}>
            <Subtitle>
                <Icon style={{fontSize: 15}} name="like" />
                {item.score}
            </Subtitle>
            <Subtitle>
                <Icon style={{fontSize: 15}} name="friends" />
                {item.by}
            </Subtitle>
            <Subtitle>
                {moment.unix(item.time).fromNow()}
            </Subtitle>
        </View>

        {item.text ? <StoryText item={item} /> : null}
    </View>
));

const StoryText = observer(({ item }) => (
    <HTMLView value={item.text} style={{paddingBottom: 10, paddingTop: 5}} addLineBreaks={false} />
));

export default Story;

The interesting part here is HTMLView. We use it to render any text a story might have – mostly seen in “Ask HN” submissions.

The component comes from James Friend’s react-native-htmlview which takes HTML and renders it as a tree of React Native <Text> components. This is useful because unlike on the web, you don’t get automatic HTML rendering in apps.

Guess what HackerNews comments and story texts are? Yep, HTML.

Comment

An individual comment is similar to a story. It renders itself, then a list of its children.

The main difference between a comment and a story is that comments have a “Show Replies” button. It toggles replies on and off, which makes the comment feed easier to read.

We use local state for that. No need to bother our MobX store with things that are this localized. Other components don’t care about a specific comment’s replies being on or off.

// src/Comment.js

import React, { Component } from 'react';
import { observer, inject } from 'mobx-react';
import { TouchableOpacity, View, Caption, Icon, Text } from '@shoutem/ui';
import moment from 'moment';
import HTMLView from 'react-native-htmlview';

import { Children } from './HNItem';

const ChildrenToggle = observer(({ item, showChildren, onPress }) => (
    <TouchableOpacity onPress={onPress}>
        <View styleName="horizontal h-end">
            <Caption>
                <Icon style={{fontSize: 12, paddingRight: 5}} name="comment" />
                {item.kids.length} {item.kids.length > 1 ? 'replies' : 'reply'}
                <Icon style={{fontSize: 8}} name={showChildren ? 'down-arrow' : 'right-arrow'}  />
            </Caption>
        </View>
    </TouchableOpacity>
));

@observer @inject('store')
class Comment extends Component {
    state = {
        showChildren: false
    }

    toggleChildren = () => this.setState({
        showChildren: !this.state.showChildren
    });

    render() {
        const { item, store } = this.props,
              { showChildren, expanded } = this.state;
        let { kids = [] } = item;

        return (
            <View style={{paddingBottom: 20}}>
                <View styleName="horizontal space-between" style={{paddingTop: 5}}>
                    <Caption>
                        <Icon style={{fontSize: 15}} name="friends" />
                        {item.by}
                    </Caption>
                    <Caption>
                        {moment.unix(item.time).fromNow()}
                    </Caption>
                    <Caption>
                        {item.sentiment.score > 0 ? 'positive' : 'neutral'}
                    </Caption>
                </View>

                <HTMLView value={item.text} paragraphBreak={'\n'} lineBreak={null} />

                {kids.length ? <ChildrenToggle item={item} showChildren={showChildren} onPress={this.toggleChildren} /> : null}

                {showChildren ? <Children item={item} /> : null}
            </View>
        );
    }
};

export default Comment;

We use ToggleChildren as a helper component to make our code more readable, use a bunch of Views and Captions to render meta data, HTMLView to render the comment itself, and the <Children> component from before to optionally render replies.

Voila

A neat little HackerNews app where people are nice. You’re welcome.

React Native HackerNews app
Done! React Native HackerNews app where people are nice.

Now how do I publish it on the App Store?