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:
parent
1a429e8552
commit
724f078cb1
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "mastui"
|
||||
version = "0.1.0"
|
||||
version = "0.1.5"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
11
src/main.rs
11
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(_) => {
|
||||
|
|
|
@ -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) = ¬ification.status {
|
||||
self.current_status_id = Some(status.id.clone());
|
||||
self.mode = AppMode::StatusDetail;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
@ -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#"<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=") {
|
||||
|
|
|
@ -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(¬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::<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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<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()
|
||||
}
|
||||
}
|
|
@ -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'), _)
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue