yReader 2022
  1. [top]
  2. # The gap
  3. # Decisions
  4. # Limitations and workarounds
  5. # Still to come
  6. # Footnotes

yReader

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

A modern 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));
        });
    }
}

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:

Footnotes

  1. Not always. [ref]