use crate::api::Status; use crate::ui::app::{App, TimelineType}; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, Frame, }; pub fn render_timeline(f: &mut Frame, app: &App, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(0)].as_ref()) .split(area); render_header(f, app, chunks[0]); render_status_list(f, app, chunks[1]); } fn render_header(f: &mut Frame, app: &App, area: Rect) { let timeline_name = match app.timeline_type { TimelineType::Home => "Home", TimelineType::Local => "Local", TimelineType::Federated => "Federated", }; let header_text = if app.loading { format!("{} Timeline (Loading...)", timeline_name) } else { format!("{} Timeline", timeline_name) }; let header = Paragraph::new(header_text) .block(Block::default().borders(Borders::ALL).title("Mastui")) .style(Style::default().fg(Color::Cyan)); f.render_widget(header, area); } fn render_status_list(f: &mut Frame, app: &App, area: Rect) { let statuses = &app.statuses; if statuses.is_empty() && !app.loading { let empty_msg = if let Some(error) = &app.error_message { format!("Error: {}", error) } else { "No statuses to display".to_string() }; let paragraph = Paragraph::new(empty_msg) .block(Block::default().borders(Borders::ALL)) .wrap(Wrap { trim: true }) .style(Style::default().fg(Color::Yellow)); f.render_widget(paragraph, area); return; } let items: Vec = statuses .iter() .enumerate() .map(|(i, status)| format_status(status, i == app.selected_status_index)) .collect(); let mut list_state = ListState::default(); list_state.select(Some(app.selected_status_index)); let list = List::new(items) .block(Block::default().borders(Borders::ALL)) .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)) .highlight_symbol("▶ "); f.render_stateful_widget(list, area, &mut list_state); } fn format_status(status: &Status, is_selected: bool) -> ListItem { let display_status = if let Some(reblog) = &status.reblog { reblog.as_ref() } else { status }; let mut lines = Vec::new(); if status.reblog.is_some() { lines.push(Line::from(vec![ Span::styled("🔄 ", Style::default().fg(Color::Green)), Span::styled( format!("{} reblogged", status.account.display_name), Style::default().fg(Color::Green), ), ])); } let username_style = if is_selected { Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::Cyan) }; lines.push(Line::from(vec![ Span::styled( format!("@{}", display_status.account.acct), username_style, ), Span::raw(" • "), Span::styled( format_time(&display_status.created_at), Style::default().fg(Color::Gray), ), ])); let content = strip_html(&display_status.content); let content_lines: Vec = textwrap::wrap(&content, 70) .into_iter() .map(|cow| cow.into_owned()) .collect(); for line in content_lines.iter().take(3) { lines.push(Line::from(Span::raw(line.clone()))); } if content_lines.len() > 3 { lines.push(Line::from(Span::styled("...", Style::default().fg(Color::Gray)))); } let mut stats = Vec::new(); if display_status.replies_count > 0 { stats.push(format!("💬 {}", display_status.replies_count)); } if display_status.reblogs_count > 0 { stats.push(format!("🔄 {}", display_status.reblogs_count)); } if display_status.favourites_count > 0 { stats.push(format!("⭐ {}", display_status.favourites_count)); } if !stats.is_empty() { lines.push(Line::from(Span::styled( stats.join(" • "), Style::default().fg(Color::Gray), ))); } if !display_status.media_attachments.is_empty() { lines.push(Line::from(Span::styled( format!("📎 {} attachment(s)", display_status.media_attachments.len()), Style::default().fg(Color::Magenta), ))); } lines.push(Line::from(Span::raw(""))); ListItem::new(Text::from(lines)) } fn strip_html(html: &str) -> String { html.replace("
", "\n") .replace("
", "\n") .replace("
", "\n") .replace("

", "\n") .chars() .filter(|&c| c != '<' && c != '>') .collect::() .replace("<", "<") .replace(">", ">") .replace("&", "&") .replace(""", "\"") .replace("'", "'") } fn format_time(time: &chrono::DateTime) -> 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() } }