A modern Hacker News desktop client

2022

The gap

If you're reading this, you have probably found yourself on Hacker News once or twice. It's a simple, beloved site where users share tech-related links and always1 have civil discussions about them. But the UX is dated, and it is not so enjoyable to browse via the official website.

I am far from the first person to come to this conclusion, as there are literally thousands of HN clients out there. But I took this opportunity to explore cross-platform graphical application development in Rust, and resolved to write my own client.

Decisions

The Rust community has been asking itself for a while now, Are we GUI yet?"and at this point, the answer is decidedly YES. I decided to go with egui: an opinionated, declarative, immediate-mode GUI library with backends for both web and native.

YReader Hacker News client

Limitations and workarounds

The Hacker News website is impressive in this day and age, in that it still runs on a single, on-prem server and has a very simple architecture. But the official Hacker News API is not great, and is "essentially a dump of [their] in-memory data structures"— making certain common actions (like listing entities) quite cumbersome. You cannot fetch a list of posts; instead, you fetch a list of post IDs, then fetch each post in separate requests. Same goes for comments and other entities.

This led me to implement a super-parallelized client that uses an absurd number of threads (one per entity) to hydrate the UI concurrently and efficiently, all at 60FPS.

impl YReader {
    fn init(&self) {
        let data_top = Arc::clone(&self.data);
        thread::spawn(move || loop {
            let client = JsonClient::new();
            let ids = client.top_stories();
            if let Ok(ids) = ids {
                let page;
                {
                    let data = data_top.lock().unwrap();
                    page = data.top_page;
                }
                for (idx, id) in ids.iter().take(WINDOW * (page + 1)).enumerate() {
                    if let Ok(item) = client.item(*id) {
                        let mut data = data_top.lock().unwrap();
                        data.top.insert(idx, item);
                    }
                }
                let mut data = data_top.lock().unwrap();
                data.top_ids = ids;
                data.top_page = (data.top_page + 1) % 2;
            }
            thread::sleep(Duration::from_secs(REFETCH_DELAY_SECONDS));
        });

        let data_new = Arc::clone(&self.data);
        thread::spawn(move || loop {
            let client = JsonClient::new();
            let ids = client.new_stories();
            if let Ok(ids) = ids {
                let page;
                {
                    let data = data_new.lock().unwrap();
                    page = data.new_page;
                }
                for (idx, id) in ids.iter().take(WINDOW * (page + 1)).enumerate() {
                    if let Ok(item) = client.item(*id) {
                        let mut data = data_new.lock().unwrap();
                        data.new.insert(idx, item);
                    }
                }
                let mut data = data_new.lock().unwrap();
                data.new_ids = ids;
                data.new_page = (data.new_page + 1) % 2;
            }
            thread::sleep(Duration::from_secs(REFETCH_DELAY_SECONDS));
        });
    }
}
Massively-concurrent data fetching to work around an awkard API

Still to come

While the client is already a great way to browse the content, I have yet to implement a few features before considering this project ready for public consumption:

  • User authentication
  • Post and comment submission/editing
  • Voting
  • Job boards

Footnotes

  1. Not always. [ref]