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]
name = "mastui"
version = "0.1.0"
version = "0.1.5"
edition = "2024"
[dependencies]

View File

@ -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

View File

@ -10,6 +10,12 @@ pub struct Config {
pub client_secret: String,
pub access_token: Option<String>,
pub username: Option<String>,
#[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(),
}
}

View File

@ -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(_) => {

View File

@ -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<Status>,
pub notifications: Vec<Notification>,
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<ExtractedLink>,
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) = &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},
};
use std::io::Cursor;
use std::sync::LazyLock;
pub struct ContentParser {
link_regex: Regex,
mention_regex: Regex,
hashtag_regex: Regex,
}
static LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"<a[^>]*href\s*=\s*["']([^"']+)["'][^>]*>(.*?)</a>"#)
.expect("Failed to compile link 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)]
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#"<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(),
}
Self
}
pub fn parse_content(&self, html: &str) -> ParsedContent {
@ -54,14 +61,11 @@ impl ContentParser {
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#"<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)) {
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=") {

View File

@ -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<B: Backend>(terminal: &mut Terminal<B>, 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<B: Backend>(terminal: &mut Terminal<B>, 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<ListItem> = notifications
.iter()
.map(|notif| {
format!(
.enumerate()
.map(|(i, notif)| {
let content = format!(
"{:?}: {} ({})",
notif.notification_type,
notif.account.display_name,
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<_>>()
.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<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::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("&#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'), _)
)
}