Hacker News App part 2 – upvoting & commenting

0:00

We’re taking the HackerNews App that filters negative comments from two weeks ago and making it actually useful. Adding a login form, enabling upvotes, and commenting.

Now that the app is useful, it’s going to the App Store. 😇

We’re using MobX for state management, a reverse-engineered HackerNews write API, and Shoutem UI Toolkit for styling. You should read this article to learn about driving simple forms with MobX: what to put in component state, what to put in MobX, how to talk to an API, performing validation, etc.

Discussion about how to reverse-engineer something like HackerNews falls outside the scope of this article. You can watch these two livestream recordings to learn about that. I’ll write an article specifically on reverse engineering HN on my personal blog.

Here, we focus on learning React Native 😁

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

indicates required



I’m going to keep this short and sweet. We’re adding 3 components:

  • Login
  • Upvote
  • Reply

Each hooks into our existing MobX Store, uses a helper class to talk to HN, and largely takes care of itself. You can see the full code on Github.

Login

React Native Hacker News App
Login form

The Login form asks you for a username and password, mimics the HackerNews login POST request, and reads the HTML response to figure out if it worked. The official HackerNews API does not support authentication.

We bring up the Login form when a user tries to upvote something or write a comment. Opening the form works through pushing a loginform state onto our navigation stack. You can see the necessary navigation changes to <App> in this diff on Github.

The <Login> component looks like this 👇

// src/Login.js

@inject('store') @observer
class Login extends Component {
    state = {
        username: '',
        password: '',
        error: '',
        loggingIn: false
    }

    changeUsername = (username) => {
        this.setState({
            username
        });
    }

    changePassword = (password) => {
        this.setState({
            password
        });
    }

    submit = () => {
        // ...
    }

    render() {
        return (
            <Screen styleName="fill-parent v-center">
                <Heading styleName="h-center" style={{paddingBottom: 10}}>
                    Login to HackerNews
                </Heading>

                <TextInput placeholder="Username"
                           onChangeText={this.changeUsername}
                           onSubmitEditing={this.submit}
                           autoCapitalize="none"
                           autoCorrect={false}
                           blurOnSubmit />
                <TextInput placeholder="Password"
                           onChangeText={this.changePassword}
                           onSubmitEditing={this.submit}
                           secureTextEntry
                           autoCapitalize="none"
                           autoCorrect={false}
                           blurOnSubmit />
                <Button onPress={this.submit}>
                    {this.state.loggingIn ? <Spinner /> : <Text>LOGIN</Text>}
                </Button>

                <Text styleName="h-center"
                      style={{marginTop: 10, color: 'red'}}>
                    {this.state.error}
                </Text>
            </Screen>
        );
    }
}

We start with default state: an empty username and password, there’s no error, and we currently aren’t loggingIn. The changeUsername and changePassword functions help us manage the two input fields. They copy text from input to state.

The render method returns a <Screen> component with a <Heading>, two managed input fields, a <Button>, and a placeholder <Text> for potential errors. Both input fields are configured to avoid changing your text with autocorrect.

You can submit by pressing the button or the submit key on your keyboard. When you do, a few things happen.

// src/Login.js

@inject('store') @observer
class Login extends Component {
    // ..
    
    submit = () => {
        const { username, password, loggingIn } = this.state,
              { store } = this.props;

        if (loggingIn) return;

        if (!username || !password) {
            this.setState({
                error: 'Missing info'
            });
        }else{
            this.setState({ loggingIn: true });
            store.login(username, password).then(success => {
                if (success) {
                    store.navigateBack();
                }else{
                    this.setState({
                        loggingIn: false,
                        error: "Bad login"
                    });
                }
            });
        }
    }
    
    // ..
}

If you’re currently loggingIn, we abort to prevent double taps. If either username or password are empty, we set the error field to say "Missing Info".

Otherwise, we set the loggingIn flag, which shows a <Spinner> inside our login button, and call the login action on our MobX store. The login action returns a promise. When that promise resolves, we navigateBack to hide the form, or update the error field to say "Bad login".

The login action sets the current user in our MobX store like this 👇

// src/Store.js

class Store {
    // ...
    @observable user = {
        username: null,
        loggedIn: false,
        actionAfterLogin: false
    };

    // ...

    @action login(username, password) {
        return HN.login(username, password)
                 .then(success => {
                     if (success) {
                         this.user.username = username;
                         this.user.loggedIn = true;

                         if (this.user.actionAfterLogin) {
                             this.user.actionAfterLogin();
                         }
                     }

                     return success;
                 });
    }
}

Our user is an observable object with a username, a loggedIn flag, and an action that should be called after successful login. This helps us inject the Login form into upvote and reply UX flows.

The login action uses our HN helper to talk to HackerNews and call any required post login actions. Talking to HackerNews happens through a single fetch() call that pretends to be the HackerNews login form.

// src/HNApi.js
class HN {
    BaseURL = 'https://news.ycombinator.com';

    login(username, password) {
        let headers = new Headers({
            "Content-Type": "application/x-www-form-urlencoded",
            "Access-Control-Allow-Origin": "*"
        });

        return fetch(`${this.BaseURL}/login`,
                     {
                         method: "POST",
                         headers: headers,
                         body: convertRequestBodyToFormUrlEncoded({
                             acct: username,
                             pw: password,
                             goto: 'news'
                         }),
                         mode: 'no-cors',
                         credentials: 'include'
                     }).then(res => res.text())
                       .then(body => {
                           if (body.match(/Bad Login/i)) {
                               return false;
                           }else{
                               return true;
                           }
                       });
    }
    
    // ...  
}

We set some headers, create a request body with our credentials, tell fetch to credentials: 'include', which makes cookies work the way you’d expect, and parse the response as text(). HackerNews responds with either the Login, or the News page.

For unsuccessful login it adds “Bad Login” to the page. We look for that with a regex. Regex is a bad way to parse HTML, but it gets the job done in this case. We’ll use CheerioJs for a more robust approach later on.

That’s the login form, let’s look at upvoting.

Upvote

Upvoting stories and comments
Upvoting stories and comments

The <Upvote> component is simpler in many ways than the login form. It’s a thumbsup icon that shows a <Spinner> while the request to HN goes through, and opens the login form if needed.

But there’s some request forgery protection on upvotes, so talking to HackerNews is trickier. We’re able to get through by pretending we’re a user’s browser 😜

The component itself looks like this 👇

// src/Upvote.js

class Upvote extends Component {
    state = {
        upvoting: false,
        upvoted: false
    }

    upvote = () => {
        const { id, store } = this.props,
              { upvoting, upvoted } = this.state;

        if (upvoting || upvoted) return;

        this.setState({
            upvoting: true
        });

        store.upvote(id)
             .then(success => {
                 this.setState({
                     upvoted: success,
                     upvoting: false
                 });
             });
    }

    render() {
        const { upvoting, upvoted } = this.state;

        if (upvoting) {
            return (<Spinner style={{width: 10, height: 10, size: "small"}} />);
        }else{
            return (
                <Icon style={{fontSize: 15, opacity: upvoted ? 0.3 : 1}}
                      name="like"
                      onPress={this.upvote} />
            )
        }
    }
}

We have two state flags: upvoting and upvoted. The first tells us if we should show a spinner, the second if we already upvoted. This creates a small UX issue because upvotes forget you’ve used them as you navigate around the app. We can fix this by moving that state into the MobX store, if users complain.

The upvote function checks both flags to avoid double taps, sets the upvoting flag, calls the upvote action on our MobX Store, and sets flags after the request goes through. We assume upvoting is always successful.

Our Store’s login action checks user state, triggers the login form, and uses our HackerNews wrapper to issue an upvote. Like this:

// src/Store.js
class Store {
    // ...
    
    @action showLoginForm() {
        this._navigationState.routes.push({
            key: 'loginform',
            type: 'loginform'
        });
        this._navigationState.index += 1;
    }

    @action upvote(id) {
        return new Promise((resolve, reject) => {
            if (!this.user.loggedIn) {
                this.user.actionAfterLogin = () => this.upvote(id)
                                                       .then(success => resolve(success));
                this.showLoginForm();
            }else{
                HN.upvote(id)
                  .then(success => resolve(success));
            }
        });
    }
}

The showLoginForm action pushes new state onto the navigation stack, which triggers the form to render.

The upvote action returns a Promise and checks if you’re is logged in. If you’re not, it sets itself as the actionAfterLogin, and calls showLoginForm. Once you’re logged in, it calls HN.upvote.

That works through two different GET requests to HackerNews. The first gets the upvote URL, the second uses it. From what I can tell, upvote URLs are unique per item per user login.

The easiest way to get around this request forgery protection is to fetch an item’s page, parse its HTML, get the URL, and use it.

// src/HNApi.js
class HN {
    // ...
    getUpvoteURL(id) {
        return fetch(`${this.BaseURL}/item?id=${id}`,
                     {
                         mode: 'no-cors',
                         credentials: 'include'
                     }).then(res => res.text())
                       .then(body => {
                           const doc = cheerio.load(body);

                           return doc(`#up_${id}`).attr('href');
                       });
    }
    
    upvote(id) {
        return this.getUpvoteURL(id)
                   .then(url => fetch(`${this.BaseURL}/${url}`, {
                       mode: 'no-cors',
                       credentials: 'include'
                   }))
                   .then(res => res.text())
                   .then(body => {
                       return true;
                   })
                   .catch(error => {
                       console.log(error);
                       return false;
                   });
    }
}

getUpvoteURL uses a fetch request to download a single item’s page, making sure to include credentials (that’s your cookie), and parses it with CheerioJs installed from the cheerio-without-node fork. This gives us a jQuery-like interface to the document, which we use to extract the URL.

We use said URL in upvote to send an upvote request to HackerNews. HN doesn’t give us any feedback on whether upvoting was successful, so we assume it was.

Reply

Positive replies in RN HackerNews app
Replying with positivity
Replying is fun. It’s a single multiline input field, but we pass your reply through Google’s Natural Language API first to make sure it’s positive. We use similar trickery as above to get around request forgery protections.

Much like <Upvote> the <Reply> component keeps most state locally. 👇

// src/Reply.js

@observer @inject('store')
class Reply extends Component {
    state = {
        text: "",
        submitting: false,
        error: null,
        replied: false
    }

    changeText = (text) => this.setState({
        text
    });

    submit = () => {
        const { submitting, text } = this.state,
              { store, id } = this.props;

        if (submitting) return;

        this.setState({
            submitting: true
        });

        store.reply(id, text)
             .then(result => {
                 this.setState({
                     error: result.error,
                     replied: !result.error,
                     submitting: false
                 });
             });
    }

    get submitStyle() {
        const { error } = this.state;

        if (error) {
            return {color: 'red'};
        }else{
            return null;
        }
    }

    render() {
        const { submitting, text, error, replied } = this.state;

        if (replied) return (<Text>{text}</Text>);

        return (
            <View styleName="vertical" style={{paddingTop: 5, paddingBottom: 10}}>
                <TextInput placeholder="Say something insightful"
                           onChangeText={this.changeText}
                           onSubmitEditing={this.submit}
                           value={text}
                           multiline={true}
                           returnKeyType="send"
                           style={{padding: 2, paddingLeft: 5, paddingRight: 5}}/>
                <Button onPress={this.submit}>
                    {submitting ? <Spinner /> : <Text style={this.submitStyle}>{error || "Submit"}</Text>}
                </Button>
            </View>
        );
    }
}

We start with an empty text, not submitting, no errors, and not having replied. Then we have a changeText function, which copies text from our input into our state.

Our submit function gets called from a button press, or submitting via the keyboard. It checks the submitting tag to avoid double sends, sets it when submitting, and uses the reply action on our MobX Store to send a comment to HackerNews.

When that promise resolves, the submit function updates local state either to show an error, or to say we’re done.

The reply action has similar logic to upvote.

// src/Store.js
class Store {
    // ...
    
    @action reply(id, text) {
        return new Promise((resolve, reject) => {
            if (!this.user.loggedIn) {
                this.user.actionAfterLogin = () => this.reply(id, text)
                                                       .then(result => resolve(result));
                this.showLoginForm();
            }else{
                this.getSentiment(text)
                    .then(json => {
                        if (json.documentSentiment.score < 0) {
                            resolve({
                                error: 'Stay positive!'
                            });
                        }else{
                            HN.reply(id, text)
                              .then(result => resolve(result));
                        }
                    });
            }
        });
    }
}

We return a Promise, wherein we trigger the login form if necessary, or use Google’s API to see if the comment is positive. If it is, we send it to HackerNews, otherwise we return an error.

The HackerNews helper class, loads a single item’s page to get the hmac argument, then sends the form as if it was a browser.

// src/HN.js
class HN {
    getHmac(id) {
        return fetch(`${this.BaseURL}/item?id=${id}`,
                     {
                         mode: 'no-cors',
                         credentials: 'include'
                     }).then(res => res.text())
                       .then(body => {
                           const doc = cheerio.load(body);

                           return doc('input[name=hmac]').attr('value');
                       });
    }

    reply(id, text) {
        return this.getHmac(id)
                   .then(hmac => {
                       let headers = new Headers({
                           "Content-Type": "application/x-www-form-urlencoded",
                           "Access-Control-Allow-Origin": "*"
                       });

                       return fetch(`${this.BaseURL}/comment`,
                                    {
                                        method: "POST",
                                        headers: headers,
                                        body: convertRequestBodyToFormUrlEncoded({
                                            parent: id,
                                            goto: `item?id=${id}`,
                                            hmac: hmac,
                                            text: text
                                        }),
                                        mode: 'no-cors',
                                        credentials: 'include'
                                    })
                   }).then(res => res.text())
                   .then(body => {
                       return {
                           success: true,
                           error: null
                       }
                   });
    }
}

getHmac loads a single item’s page, uses CheerioJs to parse its HTML, and finds the Hmac hidden input field. Similar to the getUpvoteURL function earlier.

In our reply function, we use that hmac to submit a comment. The form body has a parent ID, the hmac, and your text. Once more HackerNews doesn’t really tell us if it worked or not, so we assume it did.

Et voila

And there you have it, a HackerNews app where people are nice. Lets you browse stories, read comments, upvote things you like, and post insightful comments.

What more could you want? A lot actually, let’s call this an MVP – a minimum viable product. I’ll figure out how to put it on the app store, then we can make it better together. With your pull requests.

But what should we build next? I have some ideas and yours are probably better. Ping me on Twitter