From 724f078cb1542f2873f77598979738dc48bec904 Mon Sep 17 00:00:00 2001 From: rozodru Date: Thu, 31 Jul 2025 10:45:50 -0400 Subject: [PATCH] removed duplicate format_time, fixed unsafe unwrap() calls, fixed regex issues, enhancement of notifications, implmented profile view, made timeline refresh configurable. --- Cargo.toml | 2 +- README.md | 2 +- src/config/mod.rs | 8 +++++++ src/main.rs | 11 +++++---- src/ui/app.rs | 44 ++++++++++++++++++++++++++++------- src/ui/content.rs | 36 ++++++++++++++++------------- src/ui/mod.rs | 57 +++++++++++++++++++++++----------------------- src/ui/timeline.rs | 15 +----------- src/utils.rs | 54 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 156 insertions(+), 73 deletions(-) create mode 100644 src/utils.rs diff --git a/Cargo.toml b/Cargo.toml index 36ae77f..7c1739b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mastui" -version = "0.1.0" +version = "0.1.5" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 9f0a0e4..d710297 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ The configuration file is created automatically during the authentication proces - ✅ Account information display - ✅ Inline image display (works in compatible terminals) - ✅ Search functionality -- ✅ Auto feed update every 10 seconds +- ✅ Auto feed update (configurable interval, default 10 seconds) ## Architecture diff --git a/src/config/mod.rs b/src/config/mod.rs index fb0050a..56f3adc 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -10,6 +10,12 @@ pub struct Config { pub client_secret: String, pub access_token: Option, pub username: Option, + #[serde(default = "default_refresh_interval")] + pub refresh_interval_seconds: u64, +} + +fn default_refresh_interval() -> u64 { + 10 } impl Config { @@ -20,6 +26,7 @@ impl Config { client_secret, access_token: None, username: None, + refresh_interval_seconds: default_refresh_interval(), } } @@ -36,6 +43,7 @@ impl Config { client_secret, access_token: Some(access_token), username, + refresh_interval_seconds: default_refresh_interval(), } } diff --git a/src/main.rs b/src/main.rs index 11171d8..44c7b62 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod api; mod config; mod ui; +mod utils; use anyhow::{anyhow, Result}; use api::MastodonClient; @@ -47,10 +48,10 @@ async fn main() -> Result<()> { let client = MastodonClient::with_token( config.instance_url, - config.access_token.unwrap(), + config.access_token.expect("Config should be authenticated at this point"), ); - let app = App::new(client); + let app = App::new(client, config.refresh_interval_seconds); run_tui(app).await?; } Some(Commands::Post { content }) => { @@ -62,7 +63,7 @@ async fn main() -> Result<()> { let client = MastodonClient::with_token( config.instance_url, - config.access_token.unwrap(), + config.access_token.expect("Config should be authenticated at this point"), ); match client.post_status(&content, None, None).await { @@ -81,10 +82,10 @@ async fn main() -> Result<()> { let client = MastodonClient::with_token( config.instance_url, - config.access_token.unwrap(), + config.access_token.expect("Config should be authenticated at this point"), ); - let app = App::new(client); + let app = App::new(client, config.refresh_interval_seconds); run_tui(app).await?; } Err(_) => { diff --git a/src/ui/app.rs b/src/ui/app.rs index 9dfbd6b..23c3eda 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,4 +1,5 @@ use crate::api::{MastodonClient, Status, Notification, Context, Account}; +use crate::utils::{is_exit_key, is_up_key, is_down_key, is_home_key, is_end_key}; use crate::api::client::SearchResults; use crate::ui::content::{ContentParser, ExtractedLink}; use crate::ui::image::{MediaViewer, ImageViewer}; @@ -39,6 +40,7 @@ pub struct App { pub timeline_type: TimelineType, pub statuses: Vec, pub notifications: Vec, + pub selected_notification_index: usize, pub selected_status_index: usize, pub status_scroll: usize, pub compose_text: String, @@ -61,16 +63,18 @@ pub struct App { pub current_links: Vec, pub selected_link_index: usize, pub show_detailed_help: bool, + pub refresh_interval_seconds: u64, } impl App { - pub fn new(client: MastodonClient) -> Self { + pub fn new(client: MastodonClient, refresh_interval_seconds: u64) -> Self { Self { client: Arc::new(client), mode: AppMode::Timeline, timeline_type: TimelineType::Home, statuses: Vec::new(), notifications: Vec::new(), + selected_notification_index: 0, selected_status_index: 0, status_scroll: 0, compose_text: String::new(), @@ -93,6 +97,7 @@ impl App { current_links: Vec::new(), selected_link_index: 0, show_detailed_help: false, + refresh_interval_seconds, } } @@ -420,8 +425,13 @@ impl App { } } SearchCategory::Accounts => { - // TODO: Implement user profile view - self.error_message = Some("User profile view not implemented yet".to_string()); + if let Some(account) = results.accounts.get(self.selected_search_index) { + // Store the account and switch to profile view + self.profile_account = Some(account.clone()); + self.profile_statuses.clear(); + self.selected_profile_post_index = 0; + self.mode = AppMode::ProfileView; + } } SearchCategory::Hashtags => { if let Some(tag) = results.hashtags.get(self.selected_search_index) { @@ -638,12 +648,30 @@ impl App { } fn handle_notifications_key(&mut self, key: KeyEvent) { - use crossterm::event::{KeyCode, KeyModifiers}; + use crossterm::event::KeyCode; - match (key.code, key.modifiers) { - (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => self.mode = AppMode::Timeline, - (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.mode = AppMode::Timeline, - _ => {} + if is_exit_key(key) { + self.mode = AppMode::Timeline; + } else if is_up_key(key) { + if self.selected_notification_index > 0 { + self.selected_notification_index -= 1; + } + } else if is_down_key(key) { + if self.selected_notification_index < self.notifications.len().saturating_sub(1) { + self.selected_notification_index += 1; + } + } else if is_home_key(key) { + self.selected_notification_index = 0; + } else if is_end_key(key) { + self.selected_notification_index = self.notifications.len().saturating_sub(1); + } else if key.code == KeyCode::Enter { + // If notification has an associated status, open it + if let Some(notification) = self.notifications.get(self.selected_notification_index) { + if let Some(status) = ¬ification.status { + self.current_status_id = Some(status.id.clone()); + self.mode = AppMode::StatusDetail; + } + } } } diff --git a/src/ui/content.rs b/src/ui/content.rs index a102a21..bf30cfa 100644 --- a/src/ui/content.rs +++ b/src/ui/content.rs @@ -5,12 +5,24 @@ use ratatui::{ text::{Line, Span}, }; use std::io::Cursor; +use std::sync::LazyLock; -pub struct ContentParser { - link_regex: Regex, - mention_regex: Regex, - hashtag_regex: Regex, -} +static LINK_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r#"]*href\s*=\s*["']([^"']+)["'][^>]*>(.*?)"#) + .expect("Failed to compile link regex") +}); + +static MENTION_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r#"]*class=["'][^"']*mention[^"']*["'][^>]*href=["']([^"']*)["'][^>]*>([^<]*)"#) + .expect("Failed to compile mention regex") +}); + +static HASHTAG_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r#"]*class=["'][^"']*hashtag[^"']*["'][^>]*href=["']([^"']*)["'][^>]*>#?([^<]*)"#) + .expect("Failed to compile hashtag regex") +}); + +pub struct ContentParser; #[derive(Debug, Clone)] pub struct ParsedContent { @@ -34,12 +46,7 @@ pub enum LinkType { impl ContentParser { pub fn new() -> Self { - Self { - // More flexible regex patterns - link_regex: Regex::new(r#"]*href=['"]([^'"]*)['"][^>]*>([^<]*)"#).unwrap(), - mention_regex: Regex::new(r#"]*class=['"][^'"]*mention[^'"]*['"][^>]*href=['"]([^'"]*)['"][^>]*>([^<]*)"#).unwrap(), - hashtag_regex: Regex::new(r#"]*class=['"][^'"]*hashtag[^'"]*['"][^>]*href=['"]([^'"]*)['"][^>]*>#?([^<]*)"#).unwrap(), - } + Self } pub fn parse_content(&self, html: &str) -> ParsedContent { @@ -53,15 +60,12 @@ impl ContentParser { pub fn parse_content_with_links(&self, html: &str) -> ParsedContent { let mut content = html.to_string(); let mut extracted_links = Vec::new(); - - // More flexible regex that handles malformed attributes and various quote styles - let link_regex = Regex::new(r#"]*href\s*=\s*["']([^"']+)["'][^>]*>(.*?)"#).unwrap(); - for cap in link_regex.captures_iter(html) { + for cap in LINK_REGEX.captures_iter(html) { if let (Some(url_match), Some(text_match)) = (cap.get(1), cap.get(2)) { let url = url_match.as_str().to_string(); let text = text_match.as_str().to_string(); - let full_match = cap.get(0).unwrap().as_str(); + let full_match = cap.get(0).expect("Full match should exist").as_str(); // Determine link type based on class attributes or content let link_type = if full_match.contains("class=") { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 64ccf9f..ed7326a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,6 +6,7 @@ pub mod image; pub mod timeline; use crate::ui::app::{App, AppMode}; +use crate::utils::format_time; use anyhow::Result; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, @@ -17,7 +18,7 @@ use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Style}, text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Wrap}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, Frame, Terminal, }; use std::io; @@ -47,7 +48,7 @@ pub async fn run_tui(mut app: App) -> Result<()> { async fn run_app(terminal: &mut Terminal, app: &mut App) -> Result<()> { let mut last_refresh = std::time::Instant::now(); - let refresh_interval = Duration::from_secs(10); + let refresh_interval = Duration::from_secs(app.refresh_interval_seconds); loop { terminal.draw(|f| ui(f, app))?; @@ -101,6 +102,10 @@ async fn run_app(terminal: &mut Terminal, app: &mut App) -> Resul app.load_timeline().await?; } else if app.mode == AppMode::Notifications { app.load_notifications().await?; + } else if app.mode == AppMode::ProfileView { + if let Some(account_id) = app.profile_account.as_ref().map(|a| a.id.clone()) { + app.view_profile(&account_id).await?; + } } } _ => { @@ -262,30 +267,41 @@ fn render_notifications(f: &mut Frame, app: &App, area: Rect) { let notifications = &app.notifications; if notifications.is_empty() { - let paragraph = Paragraph::new("No notifications") + let paragraph = Paragraph::new("No notifications\n\nPress 'q' or Esc to go back") .block(Block::default().borders(Borders::ALL)) .style(Style::default().fg(Color::Yellow)); f.render_widget(paragraph, chunks[1]); } else { - let content = notifications + let items: Vec = notifications .iter() - .map(|notif| { - format!( + .enumerate() + .map(|(i, notif)| { + let content = format!( "{:?}: {} ({})", notif.notification_type, notif.account.display_name, format_time(¬if.created_at) - ) + ); + + let style = if i == app.selected_notification_index { + Style::default().fg(Color::Black).bg(Color::White) + } else { + Style::default() + }; + + ListItem::new(content).style(style) }) - .collect::>() - .join("\n"); + .collect(); - let paragraph = Paragraph::new(content) - .block(Block::default().borders(Borders::ALL)) - .wrap(ratatui::widgets::Wrap { trim: true }); + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title("Use ↑↓ to navigate, Enter to open")) + .highlight_style(Style::default().fg(Color::Black).bg(Color::White)); - f.render_widget(paragraph, chunks[1]); + let mut state = ListState::default(); + state.select(Some(app.selected_notification_index)); + + f.render_stateful_widget(list, chunks[1], &mut state); } } @@ -586,7 +602,6 @@ fn render_search(f: &mut Frame, app: &App, area: Rect) { } fn render_thread_view(f: &mut Frame, app: &App, area: Rect) { - use crate::ui::content::ContentParser; let chunks = Layout::default() .direction(Direction::Vertical) @@ -914,17 +929,3 @@ fn format_profile_post(status: &crate::api::Status, is_selected: bool) -> ratatu ratatui::widgets::ListItem::new(ratatui::text::Text::from(lines)) } -fn format_time(time: &chrono::DateTime) -> String { - let now = chrono::Utc::now(); - let duration = now.signed_duration_since(*time); - - if duration.num_days() > 0 { - format!("{}d", duration.num_days()) - } else if duration.num_hours() > 0 { - format!("{}h", duration.num_hours()) - } else if duration.num_minutes() > 0 { - format!("{}m", duration.num_minutes()) - } else { - "now".to_string() - } -} diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs index c1fd336..d02137b 100644 --- a/src/ui/timeline.rs +++ b/src/ui/timeline.rs @@ -1,6 +1,7 @@ use crate::api::Status; use crate::ui::app::{App, TimelineType}; use crate::ui::content::ContentParser; +use crate::utils::format_time; use crate::ui::image::ImageViewer; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, @@ -194,17 +195,3 @@ fn strip_html(html: &str) -> String { .replace("'", "'") } -fn format_time(time: &chrono::DateTime) -> String { - let now = chrono::Utc::now(); - let duration = now.signed_duration_since(*time); - - if duration.num_days() > 0 { - format!("{}d", duration.num_days()) - } else if duration.num_hours() > 0 { - format!("{}h", duration.num_hours()) - } else if duration.num_minutes() > 0 { - format!("{}m", duration.num_minutes()) - } else { - "now".to_string() - } -} \ No newline at end of file diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..4b4563b --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,54 @@ +use chrono::{DateTime, Utc}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +pub fn format_time(time: &DateTime) -> String { + let now = Utc::now(); + let duration = now.signed_duration_since(*time); + + if duration.num_days() > 0 { + format!("{}d", duration.num_days()) + } else if duration.num_hours() > 0 { + format!("{}h", duration.num_hours()) + } else if duration.num_minutes() > 0 { + format!("{}m", duration.num_minutes()) + } else { + format!("{}s", duration.num_seconds().max(0)) + } +} + +/// Common key patterns for navigation +pub fn is_exit_key(key: KeyEvent) -> bool { + matches!((key.code, key.modifiers), + (KeyCode::Esc, _) | + (KeyCode::Char('q'), _) | + (KeyCode::Char('c'), KeyModifiers::CONTROL) + ) +} + +pub fn is_up_key(key: KeyEvent) -> bool { + matches!((key.code, key.modifiers), + (KeyCode::Up, _) | + (KeyCode::Char('k'), _) + ) +} + +pub fn is_down_key(key: KeyEvent) -> bool { + matches!((key.code, key.modifiers), + (KeyCode::Down, _) | + (KeyCode::Char('j'), _) + ) +} + +pub fn is_home_key(key: KeyEvent) -> bool { + matches!((key.code, key.modifiers), + (KeyCode::Home, _) | + (KeyCode::Char('g'), _) + ) +} + +pub fn is_end_key(key: KeyEvent) -> bool { + matches!((key.code, key.modifiers), + (KeyCode::End, _) | + (KeyCode::Char('G'), _) + ) +} \ No newline at end of file