diff --git a/README.md b/README.md index 51beec3..3072170 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Mastui is a terminal user interface (TUI) client for Mastodon, built in Rust usi ### Building from Source ```bash -git clone https://github.com/yourusername/mastui.git +git clone https://github.com/rozodru/mastui.git cd mastui cargo build --release ``` @@ -166,4 +166,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - The Mastodon team for creating an amazing decentralized social platform - The ratatui community for the excellent TUI framework -- The Rust community for the robust ecosystem \ No newline at end of file +- The Rust community for the robust ecosystem diff --git a/src/ui/app.rs b/src/ui/app.rs index 0f40f16..9dfbd6b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -51,6 +51,9 @@ pub struct App { pub thread_statuses: Vec, pub selected_thread_index: usize, pub current_status_id: Option, + pub profile_account: Option, + pub profile_statuses: Vec, + pub selected_profile_post_index: usize, pub running: bool, pub loading: bool, pub error_message: Option, @@ -80,6 +83,9 @@ impl App { thread_statuses: Vec::new(), selected_thread_index: 0, current_status_id: None, + profile_account: None, + profile_statuses: Vec::new(), + selected_profile_post_index: 0, running: true, loading: false, error_message: None, @@ -487,6 +493,52 @@ impl App { } } + pub async fn view_profile(&mut self, account_id: &str) -> Result<()> { + self.loading = true; + self.error_message = None; + + // Load account info and recent posts in parallel + let account_future = self.client.get_account(account_id); + let statuses_future = self.client.get_account_statuses(account_id, Some(20)); + + match tokio::try_join!(account_future, statuses_future) { + Ok((account, statuses)) => { + self.profile_account = Some(account); + self.profile_statuses = statuses; + self.selected_profile_post_index = 0; + self.mode = AppMode::ProfileView; + } + Err(e) => { + self.error_message = Some(format!("Failed to load profile: {}", e)); + } + } + + self.loading = false; + Ok(()) + } + + pub fn view_profile_from_timeline(&mut self) { + if let Some(status) = self.statuses.get(self.selected_status_index) { + let account_id = status.account.id.clone(); + // This will be handled by the main event loop + self.error_message = Some(format!("Loading profile for {}", account_id)); + } else { + self.error_message = Some("No post selected".to_string()); + } + } + + pub fn next_profile_post(&mut self) { + if self.selected_profile_post_index < self.profile_statuses.len().saturating_sub(1) { + self.selected_profile_post_index += 1; + } + } + + pub fn previous_profile_post(&mut self) { + if self.selected_profile_post_index > 0 { + self.selected_profile_post_index -= 1; + } + } + pub fn toggle_help(&mut self) { self.show_detailed_help = !self.show_detailed_help; } @@ -505,6 +557,7 @@ impl App { AppMode::LinksView => self.handle_links_key(key), AppMode::Search => self.handle_search_key(key), AppMode::ThreadView => self.handle_thread_key(key), + AppMode::ProfileView => self.handle_profile_key(key), } } @@ -537,6 +590,10 @@ impl App { // View thread (h for "hierarchy" or "history") // Will be handled by main event loop }, + (KeyCode::Char('p'), _) => { + // View profile - will be handled by main event loop + self.view_profile_from_timeline(); + }, (KeyCode::F(1), _) => self.toggle_help(), (KeyCode::Enter, _) => self.mode = AppMode::StatusDetail, _ => {} @@ -565,9 +622,6 @@ impl App { (KeyCode::Backspace, _) => { self.compose_text.pop(); } - (KeyCode::Enter, KeyModifiers::CONTROL) => { - // Post the status - will be handled by the main loop - } _ => {} } } @@ -717,4 +771,46 @@ impl App { _ => {} } } + + fn handle_profile_key(&mut self, key: KeyEvent) { + use crossterm::event::{KeyCode, KeyModifiers}; + + match (key.code, key.modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => { + self.profile_account = None; + self.profile_statuses.clear(); + self.selected_profile_post_index = 0; + self.mode = AppMode::Timeline; + } + (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + self.profile_account = None; + self.profile_statuses.clear(); + self.selected_profile_post_index = 0; + self.mode = AppMode::Timeline; + } + (KeyCode::Char('j'), _) | (KeyCode::Down, _) => { + self.next_profile_post(); + } + (KeyCode::Char('k'), _) | (KeyCode::Up, _) => { + self.previous_profile_post(); + } + (KeyCode::Char('r'), _) => { + // Reply to selected post + if let Some(status) = self.profile_statuses.get(self.selected_profile_post_index) { + self.reply_to_id = Some(status.id.clone()); + self.compose_text = format!("@{} ", status.account.acct); + self.mode = AppMode::Compose; + } + } + (KeyCode::Enter, _) => { + // View selected post in detail + if let Some(status) = self.profile_statuses.get(self.selected_profile_post_index) { + self.statuses.insert(0, status.clone()); + self.selected_status_index = 0; + self.mode = AppMode::StatusDetail; + } + } + _ => {} + } + } } \ No newline at end of file diff --git a/src/ui/compose.rs b/src/ui/compose.rs index 339c7ad..9c30149 100644 --- a/src/ui/compose.rs +++ b/src/ui/compose.rs @@ -8,18 +8,39 @@ use ratatui::{ }; pub fn render_compose(f: &mut Frame, app: &App, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ + let constraints: &[Constraint] = if app.error_message.is_some() { + &[ + Constraint::Length(3), // Header + Constraint::Min(5), // Compose area + Constraint::Length(3), // Debug/Error + Constraint::Length(3), // Instructions + ] + } else { + &[ Constraint::Length(3), // Header Constraint::Min(5), // Compose area Constraint::Length(3), // Instructions - ].as_ref()) + ] + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) .split(area); render_compose_header(f, app, chunks[0]); render_compose_area(f, app, chunks[1]); - render_compose_instructions(f, chunks[2]); + + if let Some(error) = &app.error_message { + let error_widget = Paragraph::new(error.clone()) + .block(Block::default().borders(Borders::ALL).title("Debug")) + .style(Style::default().fg(Color::Red)) + .wrap(Wrap { trim: true }); + f.render_widget(error_widget, chunks[2]); + render_compose_instructions(f, chunks[3]); + } else { + render_compose_instructions(f, chunks[2]); + } } fn render_compose_header(f: &mut Frame, app: &App, area: Rect) { @@ -79,7 +100,7 @@ fn render_compose_area(f: &mut Frame, app: &App, area: Rect) { fn render_compose_instructions(f: &mut Frame, area: Rect) { let instructions = vec![ Line::from(vec![ - Span::styled("Ctrl+Enter", Style::default().fg(Color::Cyan)), + Span::styled("Enter", Style::default().fg(Color::Cyan)), Span::raw(" to post • "), Span::styled("Escape", Style::default().fg(Color::Cyan)), Span::raw(" to cancel"), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a6213bd..6dc68cd 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -60,7 +60,7 @@ async fn run_app(terminal: &mut Terminal, app: &mut App) -> Resul match event { Event::Key(key) => { match (key.code, key.modifiers) { - (KeyCode::Enter, KeyModifiers::CONTROL) if app.mode == AppMode::Compose => { + (KeyCode::Enter, KeyModifiers::NONE) if app.mode == AppMode::Compose => { app.post_status().await?; } (KeyCode::Char('f'), KeyModifiers::CONTROL) if app.mode == AppMode::Timeline => { @@ -75,6 +75,12 @@ async fn run_app(terminal: &mut Terminal, app: &mut App) -> Resul (KeyCode::Char('h'), _) if app.mode == AppMode::Timeline => { app.view_thread().await?; } + (KeyCode::Char('p'), _) if app.mode == AppMode::Timeline => { + if let Some(status) = app.statuses.get(app.selected_status_index) { + let account_id = status.account.id.clone(); + app.view_profile(&account_id).await?; + } + } (KeyCode::F(5), _) => { if app.mode == AppMode::Timeline { app.load_timeline().await?; @@ -130,6 +136,9 @@ fn ui(f: &mut Frame, app: &App) { AppMode::ThreadView => { render_thread_view(f, app, size); } + AppMode::ProfileView => { + render_profile_view(f, app, size); + } } } @@ -167,6 +176,8 @@ fn render_help(f: &mut Frame, area: Rect, app: &App) { Span::raw(" links • "), Span::styled("h", Style::default().fg(Color::Cyan)), Span::raw(" thread • "), + Span::styled("p", Style::default().fg(Color::Cyan)), + Span::raw(" profile • "), Span::styled("s", Style::default().fg(Color::Cyan)), Span::raw(" search • "), Span::styled("o", Style::default().fg(Color::Cyan)), @@ -196,6 +207,8 @@ fn render_help(f: &mut Frame, area: Rect, app: &App) { Span::raw(" search • "), Span::styled("h", Style::default().fg(Color::Cyan)), Span::raw(" thread • "), + Span::styled("p", Style::default().fg(Color::Cyan)), + Span::raw(" profile • "), Span::styled("F1", Style::default().fg(Color::Gray)), Span::raw(" more • "), Span::styled("q", Style::default().fg(Color::Cyan)), @@ -690,6 +703,200 @@ fn format_thread_status(status: &crate::api::Status, _index: usize, app: &App) - ratatui::widgets::ListItem::new(ratatui::text::Text::from(lines)) } +fn render_profile_view(f: &mut Frame, app: &App, area: Rect) { + use crate::ui::content::ContentParser; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Length(7), // Profile info + Constraint::Min(0), // Posts + Constraint::Length(3), // Instructions + ].as_ref()) + .split(area); + + // Header + let header_text = if let Some(account) = &app.profile_account { + format!("Profile: @{}", account.acct) + } else { + "Profile View".to_string() + }; + + let header = Paragraph::new(header_text) + .block(Block::default().borders(Borders::ALL).title("Mastui")) + .style(Style::default().fg(Color::Magenta)); + f.render_widget(header, chunks[0]); + + // Profile info + if let Some(account) = &app.profile_account { + let mut profile_lines = Vec::new(); + + // Display name and username + profile_lines.push(Line::from(vec![ + Span::styled( + &account.display_name, + Style::default().fg(Color::Cyan).add_modifier(ratatui::style::Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + format!("@{}", account.acct), + Style::default().fg(Color::Gray), + ), + ])); + + // Stats + profile_lines.push(Line::from(vec![ + Span::styled("Posts: ", Style::default().fg(Color::Yellow)), + Span::raw(account.statuses_count.to_string()), + Span::raw(" • "), + Span::styled("Following: ", Style::default().fg(Color::Yellow)), + Span::raw(account.following_count.to_string()), + Span::raw(" • "), + Span::styled("Followers: ", Style::default().fg(Color::Yellow)), + Span::raw(account.followers_count.to_string()), + ])); + + // Bio (if present) + if !account.note.is_empty() { + profile_lines.push(Line::from("")); + let parser = ContentParser::new(); + let bio_lines = parser.parse_content_simple(&account.note); + for line in bio_lines.iter().take(3) { + // Convert to owned spans + let owned_spans: Vec<_> = line.spans.iter() + .map(|span| Span::styled(span.content.to_string(), span.style)) + .collect(); + profile_lines.push(Line::from(owned_spans)); + } + } + + let profile_widget = Paragraph::new(ratatui::text::Text::from(profile_lines)) + .block(Block::default().borders(Borders::ALL).title("Profile Info")) + .wrap(Wrap { trim: true }); + f.render_widget(profile_widget, chunks[1]); + } else { + let loading_msg = if app.loading { + "Loading profile..." + } else { + "No profile data" + }; + + let placeholder = Paragraph::new(loading_msg) + .block(Block::default().borders(Borders::ALL).title("Profile Info")) + .style(Style::default().fg(Color::Yellow)); + f.render_widget(placeholder, chunks[1]); + } + + // Posts + if app.profile_statuses.is_empty() { + let empty_msg = if app.loading { + "Loading posts..." + } else { + "No posts found" + }; + + let paragraph = Paragraph::new(empty_msg) + .block(Block::default().borders(Borders::ALL).title("Recent Posts")) + .style(Style::default().fg(Color::Yellow)); + f.render_widget(paragraph, chunks[2]); + } else { + let items: Vec = app.profile_statuses + .iter() + .enumerate() + .map(|(i, status)| format_profile_post(status, i == app.selected_profile_post_index)) + .collect(); + + let mut list_state = ratatui::widgets::ListState::default(); + list_state.select(Some(app.selected_profile_post_index)); + + let list = ratatui::widgets::List::new(items) + .block(Block::default().borders(Borders::ALL).title("Recent Posts")) + .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(ratatui::style::Modifier::BOLD)) + .highlight_symbol("▶ "); + + f.render_stateful_widget(list, chunks[2], &mut list_state); + } + + // Instructions + let instructions = vec![ + Line::from(vec![ + Span::styled("j/k", Style::default().fg(Color::Cyan)), + Span::raw("/"), + Span::styled("↑↓", Style::default().fg(Color::Cyan)), + Span::raw(" navigate • "), + Span::styled("r", Style::default().fg(Color::Cyan)), + Span::raw(" reply • "), + Span::styled("Enter", Style::default().fg(Color::Cyan)), + Span::raw(" detail • "), + Span::styled("Esc/q", Style::default().fg(Color::Cyan)), + Span::raw(" back"), + ]), + ]; + + let help = Paragraph::new(instructions) + .block(Block::default().borders(Borders::ALL).title("Controls")) + .style(Style::default().fg(Color::Gray)); + f.render_widget(help, chunks[3]); +} + +fn format_profile_post(status: &crate::api::Status, is_selected: bool) -> ratatui::widgets::ListItem<'static> { + use crate::ui::content::ContentParser; + + let mut lines = Vec::new(); + + // Time + let time_style = if is_selected { + Style::default().fg(Color::Yellow).add_modifier(ratatui::style::Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + + lines.push(Line::from(Span::styled( + format_time(&status.created_at), + time_style, + ))); + + // Parse and display content + let parser = ContentParser::new(); + let content_lines = parser.parse_content_simple(&status.content); + + // Show first 2 lines of content + for line in content_lines.iter().take(2) { + let owned_spans: Vec<_> = line.spans.iter() + .map(|span| Span::styled(span.content.to_string(), span.style)) + .collect(); + lines.push(Line::from(owned_spans)); + } + + if content_lines.len() > 2 { + lines.push(Line::from(Span::styled("...".to_string(), Style::default().fg(Color::Gray)))); + } + + // Stats + if status.replies_count > 0 || status.reblogs_count > 0 || status.favourites_count > 0 { + let mut stats = Vec::new(); + if status.replies_count > 0 { + stats.push(format!("💬 {}", status.replies_count)); + } + if status.reblogs_count > 0 { + stats.push(format!("🔄 {}", status.reblogs_count)); + } + if status.favourites_count > 0 { + stats.push(format!("⭐ {}", status.favourites_count)); + } + + lines.push(Line::from(Span::styled( + stats.join(" • "), + Style::default().fg(Color::Gray), + ))); + } + + lines.push(Line::from("".to_string())); + + 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);