removed duplicate format_time, fixed unsafe unwrap() calls, fixed regex issues, enhancement of notifications, implmented profile view, made timeline refresh configurable.

This commit is contained in:
rozodru 2025-07-31 10:45:50 -04:00
parent 1a429e8552
commit 724f078cb1
9 changed files with 156 additions and 73 deletions

View File

@ -1,6 +1,6 @@
[package] [package]
name = "mastui" name = "mastui"
version = "0.1.0" version = "0.1.5"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -127,7 +127,7 @@ The configuration file is created automatically during the authentication proces
- ✅ Account information display - ✅ Account information display
- ✅ Inline image display (works in compatible terminals) - ✅ Inline image display (works in compatible terminals)
- ✅ Search functionality - ✅ Search functionality
- ✅ Auto feed update every 10 seconds - ✅ Auto feed update (configurable interval, default 10 seconds)
## Architecture ## Architecture

View File

@ -10,6 +10,12 @@ pub struct Config {
pub client_secret: String, pub client_secret: String,
pub access_token: Option<String>, pub access_token: Option<String>,
pub username: Option<String>, pub username: Option<String>,
#[serde(default = "default_refresh_interval")]
pub refresh_interval_seconds: u64,
}
fn default_refresh_interval() -> u64 {
10
} }
impl Config { impl Config {
@ -20,6 +26,7 @@ impl Config {
client_secret, client_secret,
access_token: None, access_token: None,
username: None, username: None,
refresh_interval_seconds: default_refresh_interval(),
} }
} }
@ -36,6 +43,7 @@ impl Config {
client_secret, client_secret,
access_token: Some(access_token), access_token: Some(access_token),
username, username,
refresh_interval_seconds: default_refresh_interval(),
} }
} }

View File

@ -1,6 +1,7 @@
mod api; mod api;
mod config; mod config;
mod ui; mod ui;
mod utils;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use api::MastodonClient; use api::MastodonClient;
@ -47,10 +48,10 @@ async fn main() -> Result<()> {
let client = MastodonClient::with_token( let client = MastodonClient::with_token(
config.instance_url, 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?; run_tui(app).await?;
} }
Some(Commands::Post { content }) => { Some(Commands::Post { content }) => {
@ -62,7 +63,7 @@ async fn main() -> Result<()> {
let client = MastodonClient::with_token( let client = MastodonClient::with_token(
config.instance_url, 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 { match client.post_status(&content, None, None).await {
@ -81,10 +82,10 @@ async fn main() -> Result<()> {
let client = MastodonClient::with_token( let client = MastodonClient::with_token(
config.instance_url, 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?; run_tui(app).await?;
} }
Err(_) => { Err(_) => {

View File

@ -1,4 +1,5 @@
use crate::api::{MastodonClient, Status, Notification, Context, Account}; 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::api::client::SearchResults;
use crate::ui::content::{ContentParser, ExtractedLink}; use crate::ui::content::{ContentParser, ExtractedLink};
use crate::ui::image::{MediaViewer, ImageViewer}; use crate::ui::image::{MediaViewer, ImageViewer};
@ -39,6 +40,7 @@ pub struct App {
pub timeline_type: TimelineType, pub timeline_type: TimelineType,
pub statuses: Vec<Status>, pub statuses: Vec<Status>,
pub notifications: Vec<Notification>, pub notifications: Vec<Notification>,
pub selected_notification_index: usize,
pub selected_status_index: usize, pub selected_status_index: usize,
pub status_scroll: usize, pub status_scroll: usize,
pub compose_text: String, pub compose_text: String,
@ -61,16 +63,18 @@ pub struct App {
pub current_links: Vec<ExtractedLink>, pub current_links: Vec<ExtractedLink>,
pub selected_link_index: usize, pub selected_link_index: usize,
pub show_detailed_help: bool, pub show_detailed_help: bool,
pub refresh_interval_seconds: u64,
} }
impl App { impl App {
pub fn new(client: MastodonClient) -> Self { pub fn new(client: MastodonClient, refresh_interval_seconds: u64) -> Self {
Self { Self {
client: Arc::new(client), client: Arc::new(client),
mode: AppMode::Timeline, mode: AppMode::Timeline,
timeline_type: TimelineType::Home, timeline_type: TimelineType::Home,
statuses: Vec::new(), statuses: Vec::new(),
notifications: Vec::new(), notifications: Vec::new(),
selected_notification_index: 0,
selected_status_index: 0, selected_status_index: 0,
status_scroll: 0, status_scroll: 0,
compose_text: String::new(), compose_text: String::new(),
@ -93,6 +97,7 @@ impl App {
current_links: Vec::new(), current_links: Vec::new(),
selected_link_index: 0, selected_link_index: 0,
show_detailed_help: false, show_detailed_help: false,
refresh_interval_seconds,
} }
} }
@ -420,8 +425,13 @@ impl App {
} }
} }
SearchCategory::Accounts => { SearchCategory::Accounts => {
// TODO: Implement user profile view if let Some(account) = results.accounts.get(self.selected_search_index) {
self.error_message = Some("User profile view not implemented yet".to_string()); // 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 => { SearchCategory::Hashtags => {
if let Some(tag) = results.hashtags.get(self.selected_search_index) { 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) { fn handle_notifications_key(&mut self, key: KeyEvent) {
use crossterm::event::{KeyCode, KeyModifiers}; use crossterm::event::KeyCode;
match (key.code, key.modifiers) { if is_exit_key(key) {
(KeyCode::Esc, _) | (KeyCode::Char('q'), _) => self.mode = AppMode::Timeline, self.mode = AppMode::Timeline;
(KeyCode::Char('c'), KeyModifiers::CONTROL) => 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) = &notification.status {
self.current_status_id = Some(status.id.clone());
self.mode = AppMode::StatusDetail;
}
}
} }
} }

View File

@ -5,12 +5,24 @@ use ratatui::{
text::{Line, Span}, text::{Line, Span},
}; };
use std::io::Cursor; use std::io::Cursor;
use std::sync::LazyLock;
pub struct ContentParser { static LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
link_regex: Regex, Regex::new(r#"<a[^>]*href\s*=\s*["']([^"']+)["'][^>]*>(.*?)</a>"#)
mention_regex: Regex, .expect("Failed to compile link regex")
hashtag_regex: Regex, });
}
static MENTION_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"<a[^>]*class=["'][^"']*mention[^"']*["'][^>]*href=["']([^"']*)["'][^>]*>([^<]*)</a>"#)
.expect("Failed to compile mention regex")
});
static HASHTAG_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"<a[^>]*class=["'][^"']*hashtag[^"']*["'][^>]*href=["']([^"']*)["'][^>]*>#?([^<]*)</a>"#)
.expect("Failed to compile hashtag regex")
});
pub struct ContentParser;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ParsedContent { pub struct ParsedContent {
@ -34,12 +46,7 @@ pub enum LinkType {
impl ContentParser { impl ContentParser {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self
// More flexible regex patterns
link_regex: Regex::new(r#"<a[^>]*href=['"]([^'"]*)['"][^>]*>([^<]*)</a>"#).unwrap(),
mention_regex: Regex::new(r#"<a[^>]*class=['"][^'"]*mention[^'"]*['"][^>]*href=['"]([^'"]*)['"][^>]*>([^<]*)</a>"#).unwrap(),
hashtag_regex: Regex::new(r#"<a[^>]*class=['"][^'"]*hashtag[^'"]*['"][^>]*href=['"]([^'"]*)['"][^>]*>#?([^<]*)</a>"#).unwrap(),
}
} }
pub fn parse_content(&self, html: &str) -> ParsedContent { pub fn parse_content(&self, html: &str) -> ParsedContent {
@ -53,15 +60,12 @@ impl ContentParser {
pub fn parse_content_with_links(&self, html: &str) -> ParsedContent { pub fn parse_content_with_links(&self, html: &str) -> ParsedContent {
let mut content = html.to_string(); let mut content = html.to_string();
let mut extracted_links = Vec::new(); let mut extracted_links = Vec::new();
// More flexible regex that handles malformed attributes and various quote styles
let link_regex = Regex::new(r#"<a[^>]*href\s*=\s*["']([^"']+)["'][^>]*>(.*?)</a>"#).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)) { if let (Some(url_match), Some(text_match)) = (cap.get(1), cap.get(2)) {
let url = url_match.as_str().to_string(); let url = url_match.as_str().to_string();
let text = text_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 // Determine link type based on class attributes or content
let link_type = if full_match.contains("class=") { let link_type = if full_match.contains("class=") {

View File

@ -6,6 +6,7 @@ pub mod image;
pub mod timeline; pub mod timeline;
use crate::ui::app::{App, AppMode}; use crate::ui::app::{App, AppMode};
use crate::utils::format_time;
use anyhow::Result; use anyhow::Result;
use crossterm::{ use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
@ -17,7 +18,7 @@ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style}, style::{Color, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
Frame, Terminal, Frame, Terminal,
}; };
use std::io; use std::io;
@ -47,7 +48,7 @@ pub async fn run_tui(mut app: App) -> Result<()> {
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> { async fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
let mut last_refresh = std::time::Instant::now(); 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 { loop {
terminal.draw(|f| ui(f, app))?; terminal.draw(|f| ui(f, app))?;
@ -101,6 +102,10 @@ async fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Resul
app.load_timeline().await?; app.load_timeline().await?;
} else if app.mode == AppMode::Notifications { } else if app.mode == AppMode::Notifications {
app.load_notifications().await?; 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; let notifications = &app.notifications;
if notifications.is_empty() { 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)) .block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Yellow)); .style(Style::default().fg(Color::Yellow));
f.render_widget(paragraph, chunks[1]); f.render_widget(paragraph, chunks[1]);
} else { } else {
let content = notifications let items: Vec<ListItem> = notifications
.iter() .iter()
.map(|notif| { .enumerate()
format!( .map(|(i, notif)| {
let content = format!(
"{:?}: {} ({})", "{:?}: {} ({})",
notif.notification_type, notif.notification_type,
notif.account.display_name, notif.account.display_name,
format_time(&notif.created_at) format_time(&notif.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::<Vec<_>>() .collect();
.join("\n");
let paragraph = Paragraph::new(content) let list = List::new(items)
.block(Block::default().borders(Borders::ALL)) .block(Block::default().borders(Borders::ALL).title("Use ↑↓ to navigate, Enter to open"))
.wrap(ratatui::widgets::Wrap { trim: true }); .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) { fn render_thread_view(f: &mut Frame, app: &App, area: Rect) {
use crate::ui::content::ContentParser;
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .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)) ratatui::widgets::ListItem::new(ratatui::text::Text::from(lines))
} }
fn format_time(time: &chrono::DateTime<chrono::Utc>) -> 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()
}
}

View File

@ -1,6 +1,7 @@
use crate::api::Status; use crate::api::Status;
use crate::ui::app::{App, TimelineType}; use crate::ui::app::{App, TimelineType};
use crate::ui::content::ContentParser; use crate::ui::content::ContentParser;
use crate::utils::format_time;
use crate::ui::image::ImageViewer; use crate::ui::image::ImageViewer;
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
@ -194,17 +195,3 @@ fn strip_html(html: &str) -> String {
.replace("&#39;", "'") .replace("&#39;", "'")
} }
fn format_time(time: &chrono::DateTime<chrono::Utc>) -> 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()
}
}

54
src/utils.rs Normal file
View File

@ -0,0 +1,54 @@
use chrono::{DateTime, Utc};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
pub fn format_time(time: &DateTime<Utc>) -> 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'), _)
)
}