diff --git a/Cargo.lock b/Cargo.lock index cc44e9c..f932f93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5308,6 +5308,7 @@ dependencies = [ "atrium-xrpc-client", "better-panic", "bstr", + "chrono", "clap", "color-eyre", "config", diff --git a/Cargo.toml b/Cargo.toml index 2e9112f..c7d2eb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ blog = [ "dep:reqwest", "dep:ipld-core", "dep:tui-markdown", + "dep:chrono" ] [dependencies] @@ -29,6 +30,7 @@ atrium-xrpc = { version = "0.12.3", optional = true } atrium-xrpc-client = { version = "0.5.14", optional = true } better-panic = "0.3.0" bstr = "1.11.3" +chrono = { version = "0.4.41", optional = true } clap = { version = "4.5.20", features = [ "derive", "cargo", diff --git a/src/components/blog.rs b/src/components/blog.rs index 8ca75fd..01fbb90 100644 --- a/src/components/blog.rs +++ b/src/components/blog.rs @@ -1,23 +1,29 @@ +use std::sync::Arc; + use color_eyre::Result; use ratatui::widgets::Widget; use tokio::sync::mpsc::UnboundedSender; use crate::action::Action; +use crate::com; use crate::components::{Component, SelectionList}; +pub type Post = Arc; #[derive(Debug)] pub struct BlogPosts { - titles: SelectionList, - contents: Vec, + list: SelectionList, + posts: Vec, in_post: Option, } impl BlogPosts { - pub fn new(posts: Vec<(String, String)>) -> Self { - let (titles, contents): (Vec, Vec) = - posts.iter().cloned().unzip(); - - Self { titles: SelectionList::new(titles), contents, in_post: None } + pub fn new(posts: Vec) -> Self { + let posts_ref = posts.to_vec(); + Self { + list: SelectionList::new(posts), + posts: posts_ref, + in_post: None, + } } pub fn is_in_post(&self) -> bool { @@ -30,18 +36,18 @@ impl Component for BlogPosts { &mut self, config: crate::config::Config, ) -> Result<()> { - self.titles.register_config_handler(config) + self.list.register_config_handler(config) } - + fn register_action_handler( &mut self, tx: UnboundedSender, ) -> Result<()> { - self.titles.register_action_handler(tx) + self.list.register_action_handler(tx) } fn update(&mut self, action: Action) -> Result> { - match self.titles.update(action.clone())?.unwrap() { + match self.list.update(action.clone())?.unwrap() { // safe to unwrap, guaranteed to not be `None` Action::Tick => {} Action::Render => {} @@ -63,15 +69,16 @@ impl Component for BlogPosts { ) -> Result<()> { if let Some(post_id_inner) = self.in_post { let post_body = self - .contents + .posts .get(post_id_inner) - .cloned() - .unwrap_or("404 - Blog not found!".to_string()); + .map_or(String::from("404 - Blog not found!"), |post| { + post.content.clone() + }); let post_widget = tui_markdown::from_str(&post_body); - post_widget.render(area, frame.buffer_mut()); + post_widget.render(area, frame.buffer_mut()); } else { - self.titles.draw(frame, area)?; + self.list.draw(frame, area)?; } Ok(()) diff --git a/src/components/content.rs b/src/components/content.rs index ebbd023..5fa49ec 100644 --- a/src/components/content.rs +++ b/src/components/content.rs @@ -10,6 +10,8 @@ use tokio::sync::mpsc::UnboundedSender; use super::Component; use crate::action::Action; +#[cfg(feature = "blog")] +use crate::components::Post; use crate::config::Config; #[derive(Default)] @@ -212,16 +214,12 @@ impl Content { /// Generate the content for the "Blog" tab #[cfg(feature = "blog")] - pub async fn blog_content(&self) -> Result> { + pub async fn blog_content(&self) -> Result> { Ok(crate::atproto::blog::get_all_posts() .await? .iter() - .map_while(|post| { - post.title - .clone() - .map(|title| (title, post.content.clone())) - }) - .collect::>()) + .map(|post| Arc::new(post.clone())) + .collect()) } } diff --git a/src/components/selection_list.rs b/src/components/selection_list.rs index 11fb5c3..4e9927d 100644 --- a/src/components/selection_list.rs +++ b/src/components/selection_list.rs @@ -1,45 +1,44 @@ +use chrono::DateTime; use color_eyre::eyre::Result; -use ratatui::style::{Color, Style}; -use ratatui::widgets::{List, ListState}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState}; use tokio::sync::mpsc::UnboundedSender; use crate::action::Action; -use crate::components::Component; +use crate::components::{Component, Post}; use crate::config::Config; -#[derive(Default, Debug)] -pub struct SelectionList { +fn truncate(s: &str, max: usize) -> String { + s.char_indices() + .find(|(idx, ch)| idx + ch.len_utf8() > max) + .map_or(s.to_string(), |(idx, _)| s[..idx].to_string() + "...") +} + +#[derive(Debug)] +pub struct SelectionList { config: Config, - options: List<'static>, - list_state: ListState, + pub(super) options: Vec, + pub(super) list_state: ListState, action_tx: Option>, } -impl SelectionList { - pub fn new(options: Vec) -> Self { +impl SelectionList { + pub fn new(options: Vec) -> Self { let mut list_state = ListState::default(); list_state.select_first(); - Self { - options: List::new(options) - .highlight_style(Style::default().fg(Color::Yellow)), - list_state, - ..Default::default() - } + Self { config: Config::default(), options, list_state, action_tx: None } } } -impl Component for SelectionList { +impl Component for SelectionList { fn register_config_handler(&mut self, config: Config) -> Result<()> { - tracing::warn!("handle config"); self.config = config; Ok(()) } - fn register_action_handler( - &mut self, - tx: UnboundedSender, - ) -> Result<()> { + fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { self.action_tx = Some(tx); Ok(()) } @@ -48,8 +47,10 @@ impl Component for SelectionList { match action { Action::Tick => {} Action::Render => {} - Action::Continue(None) => if let Some(tx) = &self.action_tx { - tx.send(Action::Continue(self.list_state.selected()))?; + Action::Continue(None) => { + if let Some(tx) = &self.action_tx { + tx.send(Action::Continue(self.list_state.selected()))?; + } } Action::SelectNext => self.list_state.select_next(), Action::SelectPrev => self.list_state.select_previous(), @@ -64,8 +65,62 @@ impl Component for SelectionList { frame: &mut ratatui::Frame, area: ratatui::prelude::Rect, ) -> Result<()> { + let items = self.options.iter().enumerate().map(|(i, post)| { + let bold_style = Style::default().add_modifier(Modifier::BOLD); + let accent_style = bold_style.fg(Color::LightMagenta); + + let post_creation_date = post + .created_at + .as_ref() + .map(|dt| DateTime::parse_from_rfc3339(dt.as_str())) + .and_then(Result::ok) + .map_or(DateTime::UNIX_EPOCH.date_naive().to_string(), |dt| { + dt.date_naive().to_string() + }); + + let arrow_or_pad = + if self.list_state.selected().is_some_and(|selection| selection == i) { + "▶ ".to_string() + } else { + format!("{:>2}", " ") + }; + + let padded_date = format!("{:>10}", post_creation_date); + + let title_spans = vec![ + Span::styled(arrow_or_pad, accent_style), + Span::raw(" "), + Span::styled(padded_date, bold_style), + Span::styled(" • ", accent_style), + Span::styled( + post.title.clone().unwrap_or("[object Object]".to_string()), // LMAOOO + accent_style, + ), + ]; + + let mut list_content = vec![Line::from(title_spans)]; + + let line_format = [ + Span::raw(format!("{:>14}", " ")), + Span::styled("┊", Style::default().add_modifier(Modifier::DIM)), + ]; + + let subtitle_span = Span::raw( + [" ", post.subtitle.as_ref().unwrap_or(&truncate(post.content.as_ref(), 40))] + .concat(), + ); + + list_content.push(Line::from([line_format.as_slice(), &[subtitle_span]].concat())); + list_content.push(Line::from([line_format.as_slice(), &[Span::raw("")]].concat())); + + ListItem::new(list_content) + }); + frame.render_stateful_widget( - self.options.clone(), + List::new(items) + .block(Block::default().borders(Borders::NONE)) + .highlight_style(Style::default()) + .highlight_symbol(""), area, &mut self.list_state, );