189 lines
5.5 KiB
Rust
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("<", "<")
|
|
.replace(">", ">")
|
|
.replace("&", "&")
|
|
.replace(""", "\"")
|
|
.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()
|
|
}
|
|
} |