summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorThang Pham <phamducthang1234@gmail.com>2023-04-24 17:45:48 -0400
committerThang Pham <phamducthang1234@gmail.com>2023-04-24 18:17:33 -0400
commiteb0fcb0c953ab26382a04520b891a1cfd130e662 (patch)
tree385d3b41cdd453d7c8accf522080fc320d6f26ba
parent902ae40f4a1fdff948cd5f15480f7353aec4e7b7 (diff)
Support opening specific comment view on startup (#90)
Resolves #86 - added `--start_id` cli option to open the application in a specific comment view - updated comment view codes to + not require a story to construct a comment view + allow constructing a comment view of either a comment or a story
-rw-r--r--hackernews_tui/src/client/mod.rs115
-rw-r--r--hackernews_tui/src/client/model.rs17
-rw-r--r--hackernews_tui/src/main.rs14
-rw-r--r--hackernews_tui/src/model.rs18
-rw-r--r--hackernews_tui/src/view/async_view.rs11
-rw-r--r--hackernews_tui/src/view/comment_view.rs59
-rw-r--r--hackernews_tui/src/view/fn_view_wrapper.rs2
-rw-r--r--hackernews_tui/src/view/mod.rs32
-rw-r--r--hackernews_tui/src/view/story_view.rs4
9 files changed, 174 insertions, 98 deletions
diff --git a/hackernews_tui/src/client/mod.rs b/hackernews_tui/src/client/mod.rs
index 9d10e94..ea70888 100644
--- a/hackernews_tui/src/client/mod.rs
+++ b/hackernews_tui/src/client/mod.rs
@@ -7,7 +7,7 @@ use std::collections::HashMap;
// re-export
pub use query::{StoryNumericFilters, StorySortMode};
-use crate::prelude::*;
+use crate::{prelude::*, utils::decode_html};
use model::*;
use rayon::prelude::*;
@@ -64,50 +64,101 @@ impl HNClient {
Ok(item)
}
- pub fn get_story_hidden_data(&self, story_id: u32) -> Result<StoryHiddenData> {
+ pub fn get_page_data(&self, item_id: u32) -> Result<PageData> {
+ // get the root item in the page
+ let request_url = format!("{HN_OFFICIAL_PREFIX}/item/{item_id}.json");
+ let item = log!(
+ self.client
+ .get(&request_url)
+ .call()?
+ .into_json::<ItemResponse>()?,
+ format!("get item (id={item_id}) using {request_url}")
+ );
+
+ // The item's text returned from HN official APIs may have `<p>` tags representing
+ // paragraph breaks. Convert `<p>` tags to newlines to make the text easier to read.
+ let text = decode_html(&item.text.unwrap_or_default()).replace("<p>", "\n\n");
+
+ // Construct the shortened text to represent the page's title if not exist
+ let chars = text.replace('\n', " ").chars().collect::<Vec<_>>();
+ let limit = 64;
+ let shortened_text = if chars.len() > limit {
+ String::from_iter(chars[..limit].iter()) + "..."
+ } else {
+ text.to_string()
+ };
+
+ let url = item
+ .url
+ .unwrap_or(format!("{HN_HOST_URL}/item?id={item_id}"));
+ let title = item.title.unwrap_or(shortened_text);
+
+ // parse the root item of the page
+ let root_item: HnItem = match item.typ.as_str() {
+ "story" => Story {
+ id: item_id,
+ url: url.clone(),
+ author: item.by.unwrap_or_default(),
+ points: item.score.unwrap_or_default(),
+ num_comments: item.descendants.unwrap_or_default(),
+ time: item.time,
+ title: title.clone(),
+ content: text,
+ }
+ .into(),
+ "comment" => Comment {
+ id: item_id,
+ level: 0,
+ n_children: 0,
+ author: item.by.unwrap_or_default(),
+ time: item.time,
+ content: text,
+ }
+ .into(),
+ typ => {
+ anyhow::bail!("unknown item type: {typ}");
+ }
+ };
+
// Parallelize two tasks using [`rayon::join`](https://docs.rs/rayon/latest/rayon/fn.join.html)
- let (content, comment_receiver) = rayon::join(
+ let (vote_state, comment_receiver) = rayon::join(
|| {
+ // get the page's vote state
log!(
- self.get_story_page_content(story_id),
- format!("get story (id={story_id}) page content")
+ {
+ let content = self.get_page_content(item_id)?;
+ self.parse_vote_data(&content)
+ },
+ format!("get page's vote state of item (id={item_id}) ")
)
},
- || self.lazy_load_story_comments(story_id),
+ // lazily load the page's top comments
+ || self.lazy_load_comments(item.kids),
);
- let content = content?;
+ let vote_state = vote_state?;
let comment_receiver = comment_receiver?;
- let vote_state = self.parse_story_vote_data(&content)?;
-
- Ok(StoryHiddenData {
+ Ok(PageData {
+ title,
+ url,
+ root_item,
comment_receiver,
vote_state,
})
}
- pub fn lazy_load_story_comments(&self, story_id: u32) -> Result<CommentReceiver> {
- // retrieve the top comments of a story
- let request_url = format!("{HN_OFFICIAL_PREFIX}/item/{story_id}.json");
- let mut ids = log!(
- self.client
- .get(&request_url)
- .call()?
- .into_json::<HNStoryResponse>()?
- .kids,
- format!("get story (id={story_id}) using {request_url}")
- );
-
+ /// lazily loads comments of a Hacker News item
+ fn lazy_load_comments(&self, mut comment_ids: Vec<u32>) -> Result<CommentReceiver> {
let (sender, receiver) = crossbeam_channel::bounded(32);
// loads the first 5 top comments to ensure the corresponding `CommentView` has data to render
- self.load_comments(&sender, &mut ids, 5)?;
+ self.load_comments(&sender, &mut comment_ids, 5)?;
std::thread::spawn({
let client = self.clone();
let sleep_dur = std::time::Duration::from_millis(1000);
move || {
- while !ids.is_empty() {
- if let Err(err) = client.load_comments(&sender, &mut ids, 5) {
+ while !comment_ids.is_empty() {
+ if let Err(err) = client.load_comments(&sender, &mut comment_ids, 5) {
warn!("encountered an error when loading comments: {}", err);
break;
}
@@ -369,18 +420,18 @@ impl HNClient {
}
}
- pub fn get_story_page_content(&self, story_id: u32) -> Result<String> {
+ /// gets the HTML page content of a Hacker News item
+ pub fn get_page_content(&self, item_id: u32) -> Result<String> {
let morelink_rg = regex::Regex::new("<a.*?href='(?P<link>.*?)'.*class='morelink'.*?>")?;
let mut content = self
.client
- .get(&format!("{HN_HOST_URL}/item?id={story_id}"))
+ .get(&format!("{HN_HOST_URL}/item?id={item_id}"))
.call()?
.into_string()?;
- // The story returned by HN can have multiple pages,
- // we need to make additional requests for each page and
- // concatenate all the responses to get the story's whole content.
+ // A Hacker News item can have multiple pages, so
+ // we need to make additional requests for each page and concatenate all the responses.
let mut curr_page_content = content.clone();
while let Some(cap) = morelink_rg.captures(&curr_page_content) {
@@ -399,12 +450,12 @@ impl HNClient {
Ok(content)
}
- /// Parse a story's vote data
+ /// Parse vote data of items in a page.
///
/// The vote data is represented by a hashmap from `id` to a struct consisting of
/// `auth` and `upvoted` (false=no vote, true=has vote), in which `id` is
/// is an item's id and `auth` is a string for authentication purpose when voting.
- pub fn parse_story_vote_data(&self, page_content: &str) -> Result<HashMap<String, VoteData>> {
+ pub fn parse_vote_data(&self, page_content: &str) -> Result<HashMap<String, VoteData>> {
let upvote_rg =
regex::Regex::new("<a.*?id='up_(?P<id>.*?)'.*?auth=(?P<auth>[0-9a-z]*).*?>")?;
let unvote_rg =
diff --git a/hackernews_tui/src/client/model.rs b/hackernews_tui/src/client/model.rs
index 82ba8c3..7ae04fe 100644
--- a/hackernews_tui/src/client/model.rs
+++ b/hackernews_tui/src/client/model.rs
@@ -60,8 +60,21 @@ pub struct StoryResponse {
}
#[derive(Debug, Deserialize)]
-/// HNStoryResponse represents the story data received from the official HackerNews APIs
-pub struct HNStoryResponse {
+/// ItemResponse represents the item data received from the official HackerNews APIs
+pub struct ItemResponse {
+ pub id: u32,
+ pub by: Option<String>,
+ pub text: Option<String>,
+ pub title: Option<String>,
+ pub url: Option<String>,
+
+ #[serde(rename(deserialize = "type"))]
+ pub typ: String,
+
+ pub descendants: Option<usize>,
+ pub score: Option<u32>,
+ pub time: u64,
+
#[serde(default)]
pub kids: Vec<u32>,
}
diff --git a/hackernews_tui/src/main.rs b/hackernews_tui/src/main.rs
index 9ac251d..927886b 100644
--- a/hackernews_tui/src/main.rs
+++ b/hackernews_tui/src/main.rs
@@ -14,7 +14,7 @@ const DEFAULT_LOG_FILE: &str = "hn-tui.log";
use clap::*;
use prelude::*;
-fn run(auth: Option<config::Auth>) {
+fn run(auth: Option<config::Auth>, start_id: Option<u32>) {
// setup HN Client
let client = client::init_client();
@@ -26,7 +26,7 @@ fn run(auth: Option<config::Auth>) {
}
// setup the application's UI
- let s = view::init_ui(client);
+ let s = view::init_ui(client, start_id);
// use `cursive_buffered_backend` crate to fix the flickering issue
// when using `cursive` with `crossterm_backend` (See https://github.com/gyscos/Cursive/issues/142)
@@ -95,6 +95,13 @@ fn parse_args(config_dir: std::path::PathBuf, cache_dir: std::path::PathBuf) ->
.help("Path to a folder to store application's logs")
.next_line_help(true),
)
+ .arg(
+ Arg::new("start_id")
+ .short('i')
+ .value_parser(clap::value_parser!(u32))
+ .help("The Hacker News item's id to start the application with")
+ .next_line_help(true),
+ )
.get_matches()
}
@@ -140,5 +147,6 @@ fn main() {
args.get_one::<String>("auth")
.expect("`auth` argument should have a default value"),
);
- run(auth);
+ let start_id = args.get_one::<u32>("start_id").cloned();
+ run(auth, start_id);
}
diff --git a/hackernews_tui/src/model.rs b/hackernews_tui/src/model.rs
index f18a75e..175c27e 100644
--- a/hackernews_tui/src/model.rs
+++ b/hackernews_tui/src/model.rs
@@ -36,8 +36,19 @@ pub struct Comment {
pub content: String,
}
-pub struct StoryHiddenData {
+/// A Hacker News page data.
+///
+/// The page data is mainly used to construct a comment view.
+pub struct PageData {
+ pub title: String,
+ pub url: String,
+
+ /// the root item in the page
+ pub root_item: HnItem,
+
+ /// a channel to lazily load items/comments in the page
pub comment_receiver: CommentReceiver,
+ /// the voting state of items in the page
pub vote_state: HashMap<String, VoteData>,
}
@@ -96,10 +107,7 @@ impl From<Story> for HnItem {
),
]);
- // The HTML story text returned by HN Algolia APIs doesn't wrap a paragraph inside a `<p><\p>` tag pair.
- // Instead, it seems to use `<p>` to represent a paragraph break.
- // Replace `<p>` with linebreaks to make the text easier to parse.
- let mut story_text = story.content.replace("<p>", "\n\n");
+ let mut story_text = story.content;
let minimized_text = if story_text.is_empty() {
metadata.clone()
diff --git a/hackernews_tui/src/view/async_view.rs b/hackernews_tui/src/view/async_view.rs
index 738322d..ca23ad3 100644
--- a/hackernews_tui/src/view/async_view.rs
+++ b/hackernews_tui/src/view/async_view.rs
@@ -7,16 +7,13 @@ use cursive_async_view::AsyncView;
pub fn construct_comment_view_async(
siv: &mut Cursive,
client: &'static client::HNClient,
- story: &Story,
+ item_id: u32,
) -> impl View {
- let id = story.id;
-
- AsyncView::new_with_bg_creator(siv, move || Ok(client.get_story_hidden_data(id)), {
- let story = story.clone();
+ AsyncView::new_with_bg_creator(siv, move || Ok(client.get_page_data(item_id)), {
move |result: Result<_>| {
ResultView::new(
- result.with_context(|| format!("failed to load comments from story (id={id})")),
- |receiver| comment_view::construct_comment_view(client, &story, receiver),
+ result.with_context(|| format!("failed to load comments from item (id={item_id})")),
+ |data| comment_view::construct_comment_view(client, data),
)
}
})
diff --git a/hackernews_tui/src/view/comment_view.rs b/hackernews_tui/src/view/comment_view.rs
index 3dbb189..38b398f 100644
--- a/hackernews_tui/src/view/comment_view.rs
+++ b/hackernews_tui/src/view/comment_view.rs
@@ -8,7 +8,7 @@ type SingleItemView = HideableView<PaddedView<text_view::TextView>>;
pub struct CommentView {
view: ScrollView<LinearLayout>,
items: Vec<HnItem>,
- data: StoryHiddenData,
+ data: PageData,
raw_command: String,
}
@@ -23,23 +23,24 @@ impl ViewWrapper for CommentView {
}
impl CommentView {
- pub fn new(story: &Story, data: StoryHiddenData) -> Self {
- // story as the first item in the comment view
- let item: HnItem = story.clone().into();
-
+ pub fn new(data: PageData) -> Self {
let mut view = CommentView {
view: LinearLayout::vertical()
.child(HideableView::new(PaddedView::lrtb(
- item.level * 2 + 1,
+ 1,
1,
0,
1,
text_view::TextView::new(
- item.text(data.vote_state.get(&item.id.to_string()).map(|v| v.upvoted)),
+ data.root_item.text(
+ data.vote_state
+ .get(&data.root_item.id.to_string())
+ .map(|v| v.upvoted),
+ ),
),
)))
.scrollable(),
- items: vec![item],
+ items: vec![data.root_item.clone()],
raw_command: String::new(),
data,
};
@@ -255,11 +256,7 @@ impl ScrollViewContainer for CommentView {
}
}
-fn construct_comment_main_view(
- client: &'static client::HNClient,
- story: &Story,
- data: StoryHiddenData,
-) -> impl View {
+fn construct_comment_main_view(client: &'static client::HNClient, data: PageData) -> impl View {
let is_suffix_key = |c: &Event| -> bool {
let comment_view_keymap = config::get_comment_view_keymap();
comment_view_keymap.open_link_in_browser.has_event(c)
@@ -268,7 +265,10 @@ fn construct_comment_main_view(
let comment_view_keymap = config::get_comment_view_keymap().clone();
- OnEventView::new(CommentView::new(story, data))
+ let article_url = data.url.clone();
+ let page_url = format!("{}/item?id={}", client::HN_HOST_URL, data.root_item.id);
+
+ OnEventView::new(CommentView::new(data))
.on_pre_event_inner(EventTrigger::from_fn(|_| true), move |s, e| {
s.try_update_comments();
@@ -389,13 +389,13 @@ fn construct_comment_main_view(
Some(EventResult::Consumed(None))
})
.on_pre_event(comment_view_keymap.open_article_in_browser, {
- let url = story.get_url().into_owned();
+ let url = article_url.clone();
move |_| {
utils::open_url_in_browser(&url);
}
})
.on_pre_event(comment_view_keymap.open_article_in_article_view, {
- let url = story.url.clone();
+ let url = article_url;
move |s| {
if !url.is_empty() {
article_view::construct_and_add_new_article_view(s, &url)
@@ -403,7 +403,7 @@ fn construct_comment_main_view(
}
})
.on_pre_event(comment_view_keymap.open_story_in_browser, {
- let url = story.story_url();
+ let url = page_url;
move |_| {
utils::open_url_in_browser(&url);
}
@@ -415,23 +415,12 @@ fn construct_comment_main_view(
.full_height()
}
-/// Construct a comment view of a given story.
-///
-/// # Arguments:
-/// * `story`: a Hacker News story
-/// * `receiver`: a "subscriber" channel that gets comments asynchronously from another thread
-pub fn construct_comment_view(
- client: &'static client::HNClient,
- story: &Story,
- data: StoryHiddenData,
-) -> impl View {
- let main_view = construct_comment_main_view(client, story, data);
+pub fn construct_comment_view(client: &'static client::HNClient, data: PageData) -> impl View {
+ let title = format!("Comment View - {}", data.title,);
+ let main_view = construct_comment_main_view(client, data);
let mut view = LinearLayout::vertical()
- .child(utils::construct_view_title_bar(&format!(
- "Comment View - {}",
- story.plain_title()
- )))
+ .child(utils::construct_view_title_bar(&title))
.child(main_view)
.child(utils::construct_footer_view::<CommentView>());
view.set_focus_index(1)
@@ -440,14 +429,14 @@ pub fn construct_comment_view(
view
}
-/// Retrieve comments of a story and construct a comment view of that story
+/// Retrieve comments in a Hacker News item and construct a comment view of that item
pub fn construct_and_add_new_comment_view(
s: &mut Cursive,
client: &'static client::HNClient,
- story: &Story,
+ item_id: u32,
pop_layer: bool,
) {
- let async_view = async_view::construct_comment_view_async(s, client, story);
+ let async_view = async_view::construct_comment_view_async(s, client, item_id);
if pop_layer {
s.pop_layer();
}
diff --git a/hackernews_tui/src/view/fn_view_wrapper.rs b/hackernews_tui/src/view/fn_view_wrapper.rs
index 710e72a..dfc0e8e 100644
--- a/hackernews_tui/src/view/fn_view_wrapper.rs
+++ b/hackernews_tui/src/view/fn_view_wrapper.rs
@@ -31,7 +31,7 @@ macro_rules! impl_view_fns_for_fn_view_wrapper {
self.get_view_mut().take_focus(source)
}
- fn call_on_any<'a>(&mut self, selector: &Selector<'_>, callback: AnyCb<'a>) {
+ fn call_on_any(&mut self, selector: &Selector<'_>, callback: AnyCb<'_>) {
self.get_view_mut().call_on_any(selector, callback)
}
diff --git a/hackernews_tui/src/view/mod.rs b/hackernews_tui/src/view/mod.rs
index bd21c60..7f01ee4 100644
--- a/hackernews_tui/src/view/mod.rs
+++ b/hackernews_tui/src/view/mod.rs
@@ -112,7 +112,10 @@ fn set_up_global_callbacks(s: &mut Cursive, client: &'static client::HNClient) {
}
/// Initialize the application's UI
-pub fn init_ui(client: &'static client::HNClient) -> cursive::CursiveRunnable {
+pub fn init_ui(
+ client: &'static client::HNClient,
+ start_id: Option<u32>,
+) -> cursive::CursiveRunnable {
let mut s = cursive::default();
// initialize `cursive` color palette which is determined by the application's theme
@@ -131,16 +134,23 @@ pub fn init_ui(client: &'static client::HNClient) -> cursive::CursiveRunnable {
set_up_global_callbacks(&mut s, client);
- // render `front_page` story view as the application's startup view
- story_view::construct_and_add_new_story_view(
- &mut s,
- client,
- "front_page",
- client::StorySortMode::None,
- 0,
- client::StoryNumericFilters::default(),
- false,
- );
+ match start_id {
+ Some(id) => {
+ comment_view::construct_and_add_new_comment_view(&mut s, client, id, false);
+ }
+ None => {
+ // render `front_page` story view as the application's startup view if no start id is specified
+ story_view::construct_and_add_new_story_view(
+ &mut s,
+ client,
+ "front_page",
+ client::StorySortMode::None,
+ 0,
+ client::StoryNumericFilters::default(),
+ false,
+ );
+ }
+ }
s
}
diff --git a/hackernews_tui/src/view/story_view.rs b/hackernews_tui/src/view/story_view.rs
index 2bb9b41..a00a347 100644
--- a/hackernews_tui/src/view/story_view.rs
+++ b/hackernews_tui/src/view/story_view.rs
@@ -168,9 +168,9 @@ pub fn construct_story_main_view(
let id = s.get_focus_index();
// the story struct hasn't had any comments inside yet,
// so it can be cloned without greatly affecting performance
- let story = s.stories[id].clone();
+ let item_id = s.stories[id].id;
Some(EventResult::with_cb({
- move |s| comment_view::construct_and_add_new_comment_view(s, client, &story, false)
+ move |s| comment_view::construct_and_add_new_comment_view(s, client, item_id, false)
}))
})
// open external link shortcuts