diff --git a/src/api/client.rs b/src/api/client.rs index 52a93fe..1b406d9 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -149,6 +149,23 @@ impl MastodonClient { Ok(account) } + pub async fn get_account(&self, id: &str) -> Result { + let endpoint = format!("/api/v1/accounts/{}", id); + let response = self.get(&endpoint).await?; + let account: Account = response.json().await?; + Ok(account) + } + + pub async fn get_account_statuses(&self, id: &str, limit: Option) -> Result> { + let mut endpoint = format!("/api/v1/accounts/{}/statuses", id); + if let Some(limit) = limit { + endpoint.push_str(&format!("?limit={}", limit)); + } + let response = self.get(&endpoint).await?; + let statuses: Vec = response.json().await?; + Ok(statuses) + } + pub async fn get_home_timeline(&self, limit: Option) -> Result> { let mut endpoint = "/api/v1/timelines/home".to_string(); if let Some(limit) = limit { diff --git a/src/ui/app.rs b/src/ui/app.rs index 04a0734..0f40f16 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,4 +1,5 @@ -use crate::api::{MastodonClient, Status, Notification}; +use crate::api::{MastodonClient, Status, Notification, Context, Account}; +use crate::api::client::SearchResults; use crate::ui::content::{ContentParser, ExtractedLink}; use crate::ui::image::{MediaViewer, ImageViewer}; use anyhow::Result; @@ -14,6 +15,8 @@ pub enum AppMode { MediaView, LinksView, Search, + ThreadView, + ProfileView, } #[derive(Debug, Clone, PartialEq)] @@ -23,6 +26,13 @@ pub enum TimelineType { Federated, } +#[derive(Debug, Clone, PartialEq)] +pub enum SearchCategory { + Posts, + Accounts, + Hashtags, +} + pub struct App { pub client: Arc, pub mode: AppMode, @@ -34,12 +44,20 @@ pub struct App { pub compose_text: String, pub reply_to_id: Option, pub search_query: String, + pub search_results: Option, + pub search_category: SearchCategory, + pub selected_search_index: usize, + pub thread_context: Option, + pub thread_statuses: Vec, + pub selected_thread_index: usize, + pub current_status_id: Option, pub running: bool, pub loading: bool, pub error_message: Option, pub media_viewer: MediaViewer, pub current_links: Vec, pub selected_link_index: usize, + pub show_detailed_help: bool, } impl App { @@ -55,12 +73,20 @@ impl App { compose_text: String::new(), reply_to_id: None, search_query: String::new(), + search_results: None, + search_category: SearchCategory::Posts, + selected_search_index: 0, + thread_context: None, + thread_statuses: Vec::new(), + selected_thread_index: 0, + current_status_id: None, running: true, loading: false, error_message: None, media_viewer: MediaViewer::new(), current_links: Vec::new(), selected_link_index: 0, + show_detailed_help: false, } } @@ -325,6 +351,146 @@ impl App { } } + pub async fn perform_search(&mut self) -> Result<()> { + if self.search_query.trim().is_empty() { + return Ok(()); + } + + self.loading = true; + self.error_message = None; + + match self.client.search(&self.search_query, true).await { + Ok(results) => { + self.search_results = Some(results); + self.selected_search_index = 0; + } + Err(e) => { + self.error_message = Some(format!("Search failed: {}", e)); + } + } + + self.loading = false; + Ok(()) + } + + pub fn next_search_category(&mut self) { + self.search_category = match self.search_category { + SearchCategory::Posts => SearchCategory::Accounts, + SearchCategory::Accounts => SearchCategory::Hashtags, + SearchCategory::Hashtags => SearchCategory::Posts, + }; + self.selected_search_index = 0; + } + + pub fn next_search_result(&mut self) { + if let Some(results) = &self.search_results { + let max_index = match self.search_category { + SearchCategory::Posts => results.statuses.len(), + SearchCategory::Accounts => results.accounts.len(), + SearchCategory::Hashtags => results.hashtags.len(), + }; + + if self.selected_search_index < max_index.saturating_sub(1) { + self.selected_search_index += 1; + } + } + } + + pub fn previous_search_result(&mut self) { + if self.selected_search_index > 0 { + self.selected_search_index -= 1; + } + } + + pub fn open_search_result(&mut self) { + if let Some(results) = &self.search_results { + match self.search_category { + SearchCategory::Posts => { + if let Some(status) = results.statuses.get(self.selected_search_index) { + // Add the status to timeline and view it + self.statuses.insert(0, status.clone()); + self.selected_status_index = 0; + self.mode = AppMode::StatusDetail; + } + } + SearchCategory::Accounts => { + // TODO: Implement user profile view + self.error_message = Some("User profile view not implemented yet".to_string()); + } + SearchCategory::Hashtags => { + if let Some(tag) = results.hashtags.get(self.selected_search_index) { + // Search for posts with this hashtag + self.search_query = format!("#{}", tag.name); + self.search_category = SearchCategory::Posts; + } + } + } + } + } + + pub async fn view_thread(&mut self) -> Result<()> { + if let Some(status) = self.statuses.get(self.selected_status_index) { + let status_id = status.id.clone(); + self.current_status_id = Some(status_id.clone()); + + self.loading = true; + self.error_message = None; + + match self.client.get_status_context(&status_id).await { + Ok(context) => { + // Build the full thread: ancestors + current status + descendants + let mut thread_statuses = context.ancestors.clone(); + thread_statuses.push(status.clone()); + thread_statuses.extend(context.descendants.clone()); + + self.thread_context = Some(context); + self.thread_statuses = thread_statuses; + + // Find the index of the current status in the thread + self.selected_thread_index = self.thread_statuses + .iter() + .position(|s| s.id == status_id) + .unwrap_or(0); + + self.mode = AppMode::ThreadView; + } + Err(e) => { + self.error_message = Some(format!("Failed to load thread: {}", e)); + } + } + + self.loading = false; + } else { + self.error_message = Some("No post selected".to_string()); + } + + Ok(()) + } + + pub fn next_thread_post(&mut self) { + if self.selected_thread_index < self.thread_statuses.len().saturating_sub(1) { + self.selected_thread_index += 1; + } + } + + pub fn previous_thread_post(&mut self) { + if self.selected_thread_index > 0 { + self.selected_thread_index -= 1; + } + } + + pub fn reply_to_thread_post(&mut self) { + if let Some(status) = self.thread_statuses.get(self.selected_thread_index) { + self.reply_to_id = Some(status.id.clone()); + self.compose_text = format!("@{} ", status.account.acct); + self.mode = AppMode::Compose; + } + } + + pub fn toggle_help(&mut self) { + self.show_detailed_help = !self.show_detailed_help; + } + pub fn quit(&mut self) { self.running = false; } @@ -338,6 +504,7 @@ impl App { AppMode::MediaView => self.handle_media_key(key), AppMode::LinksView => self.handle_links_key(key), AppMode::Search => self.handle_search_key(key), + AppMode::ThreadView => self.handle_thread_key(key), } } @@ -361,6 +528,16 @@ impl App { (KeyCode::Char('l'), _) => self.view_links(), (KeyCode::Char('d'), _) => self.debug_post(), (KeyCode::Char('o'), _) => self.open_current_media(), + (KeyCode::Char('s'), _) => { + self.search_query.clear(); + self.search_results = None; + self.mode = AppMode::Search; + }, + (KeyCode::Char('h'), _) => { + // View thread (h for "hierarchy" or "history") + // Will be handled by main event loop + }, + (KeyCode::F(1), _) => self.toggle_help(), (KeyCode::Enter, _) => self.mode = AppMode::StatusDetail, _ => {} } @@ -474,14 +651,69 @@ impl App { use crossterm::event::{KeyCode, KeyModifiers}; match (key.code, key.modifiers) { - (KeyCode::Esc, _) => self.mode = AppMode::Timeline, - (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.mode = AppMode::Timeline, - (KeyCode::Char(c), _) => { + (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => { + self.mode = AppMode::Timeline; + self.search_results = None; + } + (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + self.mode = AppMode::Timeline; + self.search_results = None; + } + (KeyCode::Char(c), _) if self.search_results.is_none() => { + // Still typing the search query self.search_query.push(c); } - (KeyCode::Backspace, _) => { + (KeyCode::Backspace, _) if self.search_results.is_none() => { self.search_query.pop(); } + (KeyCode::Enter, _) if self.search_results.is_none() => { + // Trigger search - will be handled by the main loop + } + // Navigation in search results + (KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.next_search_result(), + (KeyCode::Char('k'), _) | (KeyCode::Up, _) => self.previous_search_result(), + (KeyCode::Char('t'), _) => self.next_search_category(), + (KeyCode::Enter, _) => self.open_search_result(), + _ => {} + } + } + + fn handle_thread_key(&mut self, key: KeyEvent) { + use crossterm::event::{KeyCode, KeyModifiers}; + + match (key.code, key.modifiers) { + (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => { + self.thread_context = None; + self.thread_statuses.clear(); + self.selected_thread_index = 0; + self.current_status_id = None; + self.mode = AppMode::Timeline; + } + (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + self.thread_context = None; + self.thread_statuses.clear(); + self.selected_thread_index = 0; + self.current_status_id = None; + self.mode = AppMode::Timeline; + } + (KeyCode::Char('j'), _) | (KeyCode::Down, _) => { + self.next_thread_post(); + } + (KeyCode::Char('k'), _) | (KeyCode::Up, _) => { + self.previous_thread_post(); + } + (KeyCode::Char('r'), _) => { + self.reply_to_thread_post(); + } + (KeyCode::Enter, _) => { + // View selected post in detail + if let Some(status) = self.thread_statuses.get(self.selected_thread_index) { + // Add the status to the main timeline for detail view + self.statuses.insert(0, status.clone()); + self.selected_status_index = 0; + self.mode = AppMode::StatusDetail; + } + } _ => {} } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index fc2a927..a6213bd 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -69,6 +69,12 @@ async fn run_app(terminal: &mut Terminal, app: &mut App) -> Resul (KeyCode::Char('b'), KeyModifiers::CONTROL) if app.mode == AppMode::Timeline => { app.toggle_reblog().await?; } + (KeyCode::Enter, _) if app.mode == AppMode::Search && app.search_results.is_none() => { + app.perform_search().await?; + } + (KeyCode::Char('h'), _) if app.mode == AppMode::Timeline => { + app.view_thread().await?; + } (KeyCode::F(5), _) => { if app.mode == AppMode::Timeline { app.load_timeline().await?; @@ -94,13 +100,14 @@ fn ui(f: &mut Frame, app: &App) { match app.mode { AppMode::Timeline => { + let help_height = if app.show_detailed_help { 5 } else { 3 }; let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(0), Constraint::Length(3)].as_ref()) + .constraints([Constraint::Min(0), Constraint::Length(help_height)].as_ref()) .split(size); timeline::render_timeline(f, app, chunks[0]); - render_help(f, chunks[1]); + render_help(f, chunks[1], app); } AppMode::Compose => { compose::render_compose(f, app, size); @@ -120,43 +127,91 @@ fn ui(f: &mut Frame, app: &App) { AppMode::Search => { render_search(f, app, size); } + AppMode::ThreadView => { + render_thread_view(f, app, size); + } } } -fn render_help(f: &mut Frame, area: Rect) { - let help_text = 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("n", Style::default().fg(Color::Cyan)), - Span::raw(" new post • "), - Span::styled("t", Style::default().fg(Color::Cyan)), - Span::raw(" timeline • "), - Span::styled("Enter", Style::default().fg(Color::Cyan)), - Span::raw(" detail • "), - Span::styled("v", Style::default().fg(Color::Cyan)), - Span::raw(" media • "), - Span::styled("l", Style::default().fg(Color::Cyan)), - Span::raw(" links • "), - Span::styled("o", Style::default().fg(Color::Cyan)), - Span::raw(" open • "), - Span::styled("Ctrl+f", Style::default().fg(Color::Cyan)), - Span::raw(" fav • "), - Span::styled("Ctrl+b", Style::default().fg(Color::Cyan)), - Span::raw(" boost • "), - Span::styled("F5", Style::default().fg(Color::Cyan)), - Span::raw(" refresh • "), - Span::styled("q", Style::default().fg(Color::Cyan)), - Span::raw(" quit"), - ]), - ]; +fn render_help(f: &mut Frame, area: Rect, app: &App) { + let help_lines = if app.show_detailed_help { + // Detailed help - organized by category + vec![ + Line::from(vec![ + Span::styled("Navigation: ", Style::default().fg(Color::Yellow)), + Span::styled("j/k", Style::default().fg(Color::Cyan)), + Span::raw("/"), + Span::styled("↑↓", Style::default().fg(Color::Cyan)), + Span::raw(" move • "), + Span::styled("Enter", Style::default().fg(Color::Cyan)), + Span::raw(" detail • "), + Span::styled("t", Style::default().fg(Color::Cyan)), + Span::raw(" timeline"), + ]), + Line::from(vec![ + Span::styled("Content: ", Style::default().fg(Color::Yellow)), + Span::styled("r", Style::default().fg(Color::Cyan)), + Span::raw(" reply • "), + Span::styled("n", Style::default().fg(Color::Cyan)), + Span::raw(" new post • "), + Span::styled("Ctrl+f", Style::default().fg(Color::Cyan)), + Span::raw(" fav • "), + Span::styled("Ctrl+b", Style::default().fg(Color::Cyan)), + Span::raw(" boost"), + ]), + Line::from(vec![ + Span::styled("View: ", Style::default().fg(Color::Yellow)), + Span::styled("v", Style::default().fg(Color::Cyan)), + Span::raw(" media • "), + Span::styled("l", Style::default().fg(Color::Cyan)), + Span::raw(" links • "), + Span::styled("h", Style::default().fg(Color::Cyan)), + Span::raw(" thread • "), + Span::styled("s", Style::default().fg(Color::Cyan)), + Span::raw(" search • "), + Span::styled("o", Style::default().fg(Color::Cyan)), + Span::raw(" open"), + ]), + Line::from(vec![ + Span::styled("System: ", Style::default().fg(Color::Yellow)), + Span::styled("F5", Style::default().fg(Color::Cyan)), + Span::raw(" refresh • "), + Span::styled("F1", Style::default().fg(Color::Cyan)), + Span::raw(" toggle help • "), + Span::styled("q", Style::default().fg(Color::Cyan)), + Span::raw(" quit"), + ]), + ] + } else { + // Compact help - just essentials + vec![ + Line::from(vec![ + Span::styled("j/k", Style::default().fg(Color::Cyan)), + Span::raw(" nav • "), + Span::styled("r", Style::default().fg(Color::Cyan)), + Span::raw(" reply • "), + Span::styled("n", Style::default().fg(Color::Cyan)), + Span::raw(" post • "), + Span::styled("s", Style::default().fg(Color::Cyan)), + Span::raw(" search • "), + Span::styled("h", Style::default().fg(Color::Cyan)), + Span::raw(" thread • "), + Span::styled("F1", Style::default().fg(Color::Gray)), + Span::raw(" more • "), + Span::styled("q", Style::default().fg(Color::Cyan)), + Span::raw(" quit"), + ]), + ] + }; - let paragraph = Paragraph::new(help_text) - .block(Block::default().borders(Borders::ALL).title("Help")) + let title = if app.show_detailed_help { + "Help (F1 to hide)" + } else { + "Help (F1 for more)" + }; + + let paragraph = Paragraph::new(help_lines) + .block(Block::default().borders(Borders::ALL).title(title)) .style(Style::default().fg(Color::Gray)); f.render_widget(paragraph, area); @@ -339,12 +394,300 @@ fn render_links_view(f: &mut Frame, app: &App, area: Rect) { f.render_widget(help, chunks[2]); } -fn render_search(f: &mut Frame, _app: &App, _area: Rect) { - let paragraph = Paragraph::new("Search functionality coming soon!") - .block(Block::default().borders(Borders::ALL).title("Search")) - .style(Style::default().fg(Color::Yellow)); +fn render_search(f: &mut Frame, app: &App, area: Rect) { + use crate::ui::app::SearchCategory; + use crate::ui::content::ContentParser; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Length(3), // Search input + Constraint::Length(3), // Category tabs + Constraint::Min(0), // Results + Constraint::Length(3), // Instructions + ].as_ref()) + .split(area); - f.render_widget(paragraph, _area); + // Header + let header = Paragraph::new("Search") + .block(Block::default().borders(Borders::ALL).title("Mastui")) + .style(Style::default().fg(Color::Yellow)); + f.render_widget(header, chunks[0]); + + // Search input + let search_text = if app.loading { + format!("Searching: {}...", app.search_query) + } else { + format!("Query: {}", app.search_query) + }; + + let input = Paragraph::new(search_text) + .block(Block::default().borders(Borders::ALL).title("Search Query")) + .style(Style::default().fg(Color::White)); + f.render_widget(input, chunks[1]); + + // Category tabs (only show if we have results) + if app.search_results.is_some() { + let categories = vec!["Posts", "Accounts", "Hashtags"]; + let selected_idx = match app.search_category { + SearchCategory::Posts => 0, + SearchCategory::Accounts => 1, + SearchCategory::Hashtags => 2, + }; + + let tabs_text = categories + .iter() + .enumerate() + .map(|(i, &cat)| { + if i == selected_idx { + format!("[{}]", cat) + } else { + cat.to_string() + } + }) + .collect::>() + .join(" | "); + + let tabs = Paragraph::new(tabs_text) + .block(Block::default().borders(Borders::ALL).title("Categories")) + .style(Style::default().fg(Color::Cyan)); + f.render_widget(tabs, chunks[2]); + } + + // Results + if let Some(results) = &app.search_results { + let content = match app.search_category { + SearchCategory::Posts => { + if results.statuses.is_empty() { + "No posts found".to_string() + } else { + let parser = ContentParser::new(); + results.statuses + .iter() + .enumerate() + .map(|(i, status)| { + let prefix = if i == app.selected_search_index { "▶ " } else { " " }; + let content = parser.parse_content_simple(&status.content); + let first_line = content.first() + .map(|line| line.spans.iter().map(|span| span.content.as_ref()).collect::()) + .unwrap_or_else(|| "No content".to_string()); + format!("{}@{}: {}", prefix, status.account.acct, + if first_line.len() > 80 { format!("{}...", &first_line[..80]) } else { first_line }) + }) + .collect::>() + .join("\n") + } + } + SearchCategory::Accounts => { + if results.accounts.is_empty() { + "No accounts found".to_string() + } else { + results.accounts + .iter() + .enumerate() + .map(|(i, account)| { + let prefix = if i == app.selected_search_index { "▶ " } else { " " }; + format!("{}@{}: {}", prefix, account.acct, account.display_name) + }) + .collect::>() + .join("\n") + } + } + SearchCategory::Hashtags => { + if results.hashtags.is_empty() { + "No hashtags found".to_string() + } else { + results.hashtags + .iter() + .enumerate() + .map(|(i, tag)| { + let prefix = if i == app.selected_search_index { "▶ " } else { " " }; + format!("{}#{}", prefix, tag.name) + }) + .collect::>() + .join("\n") + } + } + }; + + let results_widget = Paragraph::new(content) + .block(Block::default().borders(Borders::ALL).title("Results")) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(Color::White)); + f.render_widget(results_widget, chunks[3]); + } else if !app.search_query.is_empty() && !app.loading { + let msg = Paragraph::new("Press Enter to search") + .block(Block::default().borders(Borders::ALL).title("Results")) + .style(Style::default().fg(Color::Gray)); + f.render_widget(msg, chunks[3]); + } + + // Instructions + let instructions = if app.search_results.is_some() { + vec![ + Line::from(vec![ + Span::styled("j/k", Style::default().fg(Color::Cyan)), + Span::raw(" navigate • "), + Span::styled("t", Style::default().fg(Color::Cyan)), + Span::raw(" switch category • "), + Span::styled("Enter", Style::default().fg(Color::Cyan)), + Span::raw(" open • "), + Span::styled("Esc/q", Style::default().fg(Color::Cyan)), + Span::raw(" back"), + ]), + ] + } else { + vec![ + Line::from(vec![ + Span::raw("Type to search • "), + Span::styled("Enter", Style::default().fg(Color::Cyan)), + Span::raw(" to search • "), + Span::styled("Esc/q", Style::default().fg(Color::Cyan)), + Span::raw(" to go back"), + ]), + ] + }; + + let help = Paragraph::new(instructions) + .block(Block::default().borders(Borders::ALL).title("Instructions")) + .style(Style::default().fg(Color::Gray)); + f.render_widget(help, chunks[4]); +} + +fn render_thread_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::Min(0), // Thread content + Constraint::Length(3), // Instructions + ].as_ref()) + .split(area); + + // Header + let thread_info = if let Some(current_id) = &app.current_status_id { + format!("Thread View ({})", current_id) + } else { + "Thread View".to_string() + }; + + let header = Paragraph::new(thread_info) + .block(Block::default().borders(Borders::ALL).title("Mastui")) + .style(Style::default().fg(Color::Green)); + f.render_widget(header, chunks[0]); + + // Thread content + if app.thread_statuses.is_empty() { + let empty_msg = if app.loading { + "Loading thread..." + } else { + "No thread data" + }; + + let paragraph = Paragraph::new(empty_msg) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Yellow)); + f.render_widget(paragraph, chunks[1]); + } else { + let items: Vec = app.thread_statuses + .iter() + .enumerate() + .map(|(i, status)| format_thread_status(status, i, app)) + .collect(); + + let mut list_state = ratatui::widgets::ListState::default(); + list_state.select(Some(app.selected_thread_index)); + + let list = ratatui::widgets::List::new(items) + .block(Block::default().borders(Borders::ALL).title("Conversation")) + .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(ratatui::style::Modifier::BOLD)) + .highlight_symbol("▶ "); + + f.render_stateful_widget(list, chunks[1], &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[2]); +} + +fn format_thread_status(status: &crate::api::Status, _index: usize, app: &App) -> ratatui::widgets::ListItem<'static> { + use crate::ui::content::ContentParser; + + let mut lines = Vec::new(); + + // Determine the relationship and styling + let (prefix, style) = if let Some(current_id) = &app.current_status_id { + if status.id == *current_id { + ("● ", Style::default().fg(Color::Yellow).add_modifier(ratatui::style::Modifier::BOLD)) // Current post + } else if let Some(context) = &app.thread_context { + if context.ancestors.iter().any(|s| s.id == status.id) { + ("↑ ", Style::default().fg(Color::Blue)) // Ancestor + } else { + ("↓ ", Style::default().fg(Color::Green)) // Descendant + } + } else { + (" ", Style::default()) + } + } else { + (" ", Style::default()) + }; + + // User info - use owned strings + lines.push(Line::from(vec![ + Span::styled(prefix.to_string(), style), + Span::styled( + format!("@{}", status.account.acct), + Style::default().fg(Color::Cyan).add_modifier(ratatui::style::Modifier::BOLD), + ), + Span::raw(" • ".to_string()), + Span::styled( + format_time(&status.created_at), + Style::default().fg(Color::Gray), + ), + ])); + + // 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) { + // Convert to owned spans + 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)))); + } + + // Add some spacing + lines.push(Line::from("".to_string())); + + ratatui::widgets::ListItem::new(ratatui::text::Text::from(lines)) } fn format_time(time: &chrono::DateTime) -> String {