From e3e30663c390a4026e2e4fef714308b945090d15 Mon Sep 17 00:00:00 2001 From: Luna <46259660+LunaticHacker@users.noreply.github.com> Date: Wed, 8 Dec 2021 21:06:13 +0530 Subject: [PATCH] Sort comments (#10) * local recursive sort for comments and ui for #9 * add hot rank sorting for comments * add old sort for posts --- Cargo.lock | 43 ++++++++++++++++++ Cargo.toml | 1 + src/api.rs | 30 ++++-------- src/event.rs | 1 + src/main.rs | 30 ++++++++++++ src/ui.rs | 126 ++++++++++++++++++++++++++++----------------------- src/utils.rs | 61 +++++++++++++++++++++++++ 7 files changed, 215 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a2d2f63..fb1d896 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,19 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "time", + "winapi", +] + [[package]] name = "core-foundation" version = "0.9.1" @@ -358,6 +371,7 @@ dependencies = [ name = "ltv" version = "0.1.0" dependencies = [ + "chrono", "directories", "reqwest", "rpassword", @@ -435,6 +449,25 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.13.0" @@ -795,6 +828,16 @@ dependencies = [ "redox_termios", ] +[[package]] +name = "time" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "tinyvec" version = "1.4.0" diff --git a/Cargo.toml b/Cargo.toml index 79f11d2..aa30c06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ serde_json = "1.0" directories = "3.0.2" toml = "0.5.8" rpassword = "5.0.1" +chrono = "0.4" [patch.crates-io] tui = {git = "https://github.com/LunaticHacker/tui-rs", branch = "for-ltv"} diff --git a/src/api.rs b/src/api.rs index 4681f4f..dcd7a06 100644 --- a/src/api.rs +++ b/src/api.rs @@ -41,12 +41,14 @@ pub struct Comment { pub id: i32, pub content: String, pub parent_id: Option, + pub published: String, } #[derive(Deserialize, Default, Clone)] pub struct CommentInfo { - pub comment: Option, + pub comment: Comment, pub creator: Creator, + pub counts: CommentCounts, //There are more fields but we don't care } @@ -71,19 +73,7 @@ impl CommentTree { fn fill_children(mut self, comments: &Vec) -> Self { for i in 0..comments.len() { let clone = comments.clone(); - if comments[i] - .comment - .as_ref() - .unwrap_or(&Comment::default()) - .parent_id - .unwrap_or_default() - == self - .comment - .comment - .as_ref() - .unwrap_or(&Comment::default()) - .id - { + if comments[i].comment.parent_id.unwrap_or_default() == self.comment.comment.id { self.children .push(CommentTree::new(&comments[i]).fill_children(&clone)); } @@ -120,6 +110,10 @@ pub struct Community { pub struct PostCounts { pub comments: i64, } +#[derive(Deserialize, Default, Clone)] +pub struct CommentCounts { + pub score: i64, +} //Api Fetching Functions pub fn get_posts(url: String, auth: &str, config: &str) -> Result, reqwest::Error> { @@ -142,13 +136,7 @@ pub fn get_comments(url: String, auth: &str) -> Result, reqwest let clone = comments.clone(); let filtered_comments: Vec = comments .into_iter() - .filter(|c| { - !c.comment - .as_ref() - .unwrap_or(&Comment::default()) - .parent_id - .is_some() - }) + .filter(|c| !c.comment.parent_id.is_some()) .collect(); let result = utils::map_tree(filtered_comments); //result.iter().map(|r|r.fill_children(&clone)).collect() diff --git a/src/event.rs b/src/event.rs index 33ee9ec..7c258ba 100644 --- a/src/event.rs +++ b/src/event.rs @@ -13,6 +13,7 @@ pub enum Event { /// A small event handler that wrap termion input and tick events. Each event /// type is handled in its own thread and returned to a common `Receiver` +#[allow(dead_code)] pub struct Events { rx: mpsc::Receiver>, input_handle: thread::JoinHandle<()>, diff --git a/src/main.rs b/src/main.rs index 53b4840..8e79ad8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -122,6 +122,7 @@ fn main() -> Result<(), io::Error> { &format!("&limit={}&type_={}&sort=New", limit, type_), ) .unwrap_or_default(); + app.unselect(); } else if let Key::Char('2') = input { //unwrap is fine we will have a config always let limit = conf.params.get("limit").unwrap(); @@ -132,6 +133,7 @@ fn main() -> Result<(), io::Error> { &format!("&limit={}&type_={}&sort=Hot", limit, type_), ) .unwrap_or_default(); + app.unselect(); } else if let Key::Char('3') = input { //unwrap is fine we will have a config always let limit = conf.params.get("limit").unwrap(); @@ -142,6 +144,19 @@ fn main() -> Result<(), io::Error> { &format!("&limit={}&type_={}&sort=Active", limit, type_), ) .unwrap_or_default(); + app.unselect(); + } else if let Key::Char('4') = input { + //unwrap is fine we will have a config always + let limit = conf.params.get("limit").unwrap(); + let type_ = conf.params.get("type_").unwrap(); + app.posts = api::get_posts( + format!("{}/api/v3/post/list?", &app.instance), + &app.auth, + &format!("&limit={}&type_={}&sort=New", limit, type_), + ) + .unwrap_or_default(); + app.posts.reverse(); + app.unselect(); } } else if let InputMode::Editing = &app.input_mode { if let Key::Left = input { @@ -234,6 +249,21 @@ fn main() -> Result<(), io::Error> { } } } + } else if let Key::Char('1') = input { + if app.replies.is_empty() { + utils::sort(utils::SortType::New, &mut app.comments); + app.c_unselect() + } + } else if let Key::Char('2') = input { + if app.replies.is_empty() { + utils::sort(utils::SortType::Old, &mut app.comments); + app.c_unselect() + } + } else if let Key::Char('3') = input { + if app.replies.is_empty() { + utils::sort(utils::SortType::Hot, &mut app.comments); + app.c_unselect() + } } else if let Key::Char('q') = input { break 'outer; } diff --git a/src/ui.rs b/src/ui.rs index 2d60ee8..bd64e23 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -17,7 +17,14 @@ where { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Length(7)].as_ref()) + .constraints( + [ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(7), + ] + .as_ref(), + ) .split(frame.size()); let input_block = Paragraph::new(tui::text::Text::from(app.input.clone())) .style(match app.input_mode { @@ -77,7 +84,14 @@ where .highlight_symbol(tui::symbols::line::VERTICAL) .repeat_highlight_symbol(true); - frame.render_stateful_widget(list, chunks[1], &mut app.state); + frame.render_stateful_widget(list, chunks[2], &mut app.state); + + let sort_text = Paragraph::new(" New[1] Hot[2] Active[3] Old[4]").style( + Style::default() + .fg(*app.theme.get(PRIMARY).unwrap()) + .bg(*app.theme.get(BG).unwrap()), + ); + frame.render_widget(sort_text, chunks[1]); } //renders the ui when InputMode is PostView pub fn draw_post(app: &mut LApp, frame: &mut Frame) @@ -169,37 +183,34 @@ where { let mut items = vec![]; for comment in &app.comments { - //Comment can be null :( - if let Some(c) = comment.comment.comment.as_ref() { - let mut t = WrappedText::new(frame.size().width - 10); - t.extend(Text::from(vec![ - Spans::from(vec![Span::styled( - &comment.comment.creator.name, - Style::default() - .fg(*app.theme.get(SECONDARY).unwrap()) - .bg(*app.theme.get(BG).unwrap()) - .add_modifier(Modifier::UNDERLINED), - )]), - Spans::from(c.content.as_ref()), - Spans::from(vec![ - Span::styled( - format!("{}", comment.children.len()), - Style::default().fg(*app.theme.get(SECONDARY).unwrap()), - ), - Span::styled( - " Replies", - Style::default().fg(*app.theme.get(SECONDARY).unwrap()), - ), - ]), - Spans::from(""), - ])); - items.push(ListItem::new(t)) - } + let mut t = WrappedText::new(frame.size().width - 10); + t.extend(Text::from(vec![ + Spans::from(vec![Span::styled( + &comment.comment.creator.name, + Style::default() + .fg(*app.theme.get(SECONDARY).unwrap()) + .bg(*app.theme.get(BG).unwrap()) + .add_modifier(Modifier::UNDERLINED), + )]), + Spans::from(comment.comment.comment.content.as_ref()), + Spans::from(vec![ + Span::styled( + format!("{}", comment.children.len()), + Style::default().fg(*app.theme.get(SECONDARY).unwrap()), + ), + Span::styled( + " Replies", + Style::default().fg(*app.theme.get(SECONDARY).unwrap()), + ), + ]), + Spans::from(""), + ])); + items.push(ListItem::new(t)) } if let (_, true) = (&app.comments, app.replies.is_empty()) { let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Percentage(100)]) + .constraints([Constraint::Length(1), Constraint::Length(7)]) .split(frame.size()); let list = List::new(items) .block(Block::default().title("Comments").borders(Borders::ALL)) @@ -210,7 +221,13 @@ where ) .highlight_symbol(tui::symbols::line::VERTICAL) .repeat_highlight_symbol(true); - frame.render_stateful_widget(list, chunks[0], &mut app.comment_state); + let sort_text = Paragraph::new(" New[1] Old[2] Hot[3]").style( + Style::default() + .fg(*app.theme.get(PRIMARY).unwrap()) + .bg(*app.theme.get(BG).unwrap()), + ); + frame.render_widget(sort_text, chunks[0]); + frame.render_stateful_widget(list, chunks[1], &mut app.comment_state); } else if let (_, false) = (&app.comments, app.replies.is_empty()) { let chunks = Layout::default() .direction(Direction::Vertical) @@ -220,32 +237,29 @@ where let mut items = vec![]; for comment in &app.replies { - //Comment can be null :( - if let Some(c) = comment.comment.comment.as_ref() { - let mut t = WrappedText::new(frame.size().width - 10); - t.extend(Text::from(vec![ - Spans::from(vec![Span::styled( - &comment.comment.creator.name, - Style::default() - .fg(*app.theme.get(SECONDARY).unwrap()) - .bg(*app.theme.get(BG).unwrap()) - .add_modifier(Modifier::UNDERLINED), - )]), - Spans::from(c.content.as_ref()), - Spans::from(vec![ - Span::styled( - format!("{}", comment.children.len()), - Style::default().fg(*app.theme.get(SECONDARY).unwrap()), - ), - Span::styled( - " Replies", - Style::default().fg(*app.theme.get(SECONDARY).unwrap()), - ), - ]), - Spans::from(""), - ])); - items.push(ListItem::new(t)) - } + let mut t = WrappedText::new(frame.size().width - 10); + t.extend(Text::from(vec![ + Spans::from(vec![Span::styled( + &comment.comment.creator.name, + Style::default() + .fg(*app.theme.get(SECONDARY).unwrap()) + .bg(*app.theme.get(BG).unwrap()) + .add_modifier(Modifier::UNDERLINED), + )]), + Spans::from(comment.comment.comment.content.as_ref()), + Spans::from(vec![ + Span::styled( + format!("{}", comment.children.len()), + Style::default().fg(*app.theme.get(SECONDARY).unwrap()), + ), + Span::styled( + " Replies", + Style::default().fg(*app.theme.get(SECONDARY).unwrap()), + ), + ]), + Spans::from(""), + ])); + items.push(ListItem::new(t)) } let list = List::new(items) diff --git a/src/utils.rs b/src/utils.rs index 45ef8bf..14ec893 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,6 +1,13 @@ use super::api::{CommentInfo, CommentTree}; +use chrono::NaiveDateTime; use std::collections::HashMap; +use std::str::FromStr; use tui::style::Color; +pub enum SortType { + Hot, + Old, + New, +} pub fn map_tree(list: Vec) -> Vec { list.into_iter() .map(|ct| CommentTree { @@ -72,3 +79,57 @@ pub fn colorify(list: HashMap) -> HashMap { } result } +//TODO: Refactor this +pub fn sort(st: SortType, ct: &mut Vec) { + match st { + SortType::New => { + ct.sort_by(|b, a| { + NaiveDateTime::from_str(&a.comment.comment.published) + .unwrap() + .cmp(&NaiveDateTime::from_str(&b.comment.comment.published).unwrap()) + }); + for c in ct { + sort(SortType::New, &mut c.children); + } + } + SortType::Old => { + ct.sort_by(|a, b| { + NaiveDateTime::from_str(&a.comment.comment.published) + .unwrap() + .cmp(&NaiveDateTime::from_str(&b.comment.comment.published).unwrap()) + }); + for c in ct { + sort(SortType::Old, &mut c.children); + } + } + SortType::Hot => { + ct.sort_by(|b, a| { + let rank = + calculate_hot_rank(a.comment.counts.score, a.comment.comment.published.clone()) + .partial_cmp(&calculate_hot_rank( + b.comment.counts.score, + b.comment.comment.published.clone(), + )); + match rank { + Some(r) => r, + None => std::cmp::Ordering::Equal, + } + }); + for c in ct { + sort(SortType::Hot, &mut c.children); + } + } + } +} +// TODO: Looks correct from some manual tests. but verify properly later +// Code from https://github.com/LemmyNet/lemmy-ui/blob/a11cbb29c73107fcc7a629e7b0babdf939520675/src/shared/utils.ts#L269 +pub fn calculate_hot_rank(score: i64, timestr: String) -> f64 { + let elapsed = (chrono::offset::Utc::now().timestamp_millis() + - chrono::NaiveDateTime::from_str(×tr) + .unwrap() + .timestamp_millis()) + / 3600000; + let elapsed_base: f64 = (elapsed + 2) as f64; + let max = std::cmp::max(1, 3 + score) as f64; + (10000 as f64 * max.log10()) / elapsed_base.powf(1.8) +}