Mastui/src/ui/timeline.rs

189 lines
5.5 KiB
Rust

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<ListItem> = 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<String> = 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("<br>", "\n")
.replace("<br/>", "\n")
.replace("<br />", "\n")
.replace("</p>", "\n")
.chars()
.filter(|&c| c != '<' && c != '>')
.collect::<String>()
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.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()
}
}