added ability to view images and navigate links in posts

This commit is contained in:
rozodru 2025-07-20 12:40:54 -04:00
parent e7dcba39a6
commit f92e4f3130
9 changed files with 1457 additions and 56 deletions

703
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -16,3 +16,7 @@ anyhow = "1.0"
clap = { version = "4.0", features = ["derive"] } clap = { version = "4.0", features = ["derive"] }
urlencoding = "2.1" urlencoding = "2.1"
textwrap = "0.16" textwrap = "0.16"
html2text = "0.13"
regex = "1.10"
image = "0.24"
viuer = "0.7"

View File

@ -77,6 +77,7 @@ mastui post "Hello from the terminal! #mastui"
- `n` - Compose new post - `n` - Compose new post
- `t` - Switch between timelines (Home → Local → Federated) - `t` - Switch between timelines (Home → Local → Federated)
- `m` - View notifications - `m` - View notifications
- `v` - View media attachments
- `Ctrl+f` - Favorite/unfavorite status - `Ctrl+f` - Favorite/unfavorite status
- `Ctrl+b` - Boost/unboost status - `Ctrl+b` - Boost/unboost status
- `F5` - Refresh timeline - `F5` - Refresh timeline
@ -93,6 +94,13 @@ mastui post "Hello from the terminal! #mastui"
- `r` - Reply to status - `r` - Reply to status
- `Escape` / `q` - Return to timeline - `Escape` / `q` - Return to timeline
### Media View
- `j` / `↓` - Next media item
- `k` / `↑` - Previous media item
- `Enter` / `o` - Open media externally
- `Escape` / `q` - Close media view
### General ### General
- `Ctrl+C` - Force quit from any view - `Ctrl+C` - Force quit from any view
@ -114,9 +122,10 @@ The configuration file is created automatically during the authentication proces
- ✅ Post creation and replies - ✅ Post creation and replies
- ✅ Status interactions (favorite, boost) - ✅ Status interactions (favorite, boost)
- ✅ Notification viewing - ✅ Notification viewing
- ✅ Media attachment display (URLs shown) - ✅ Media attachment viewing and external opening
- ✅ Improved HTML content parsing with styled links
- ✅ Account information display - ✅ Account information display
- ⚠️ Image viewing (planned) - ⚠️ Inline image display (works in compatible terminals)
- ⚠️ Search functionality (planned) - ⚠️ Search functionality (planned)
- ⚠️ Direct messages (planned) - ⚠️ Direct messages (planned)

View File

@ -1,4 +1,6 @@
use crate::api::{MastodonClient, Status, Notification}; use crate::api::{MastodonClient, Status, Notification};
use crate::ui::content::{ContentParser, ExtractedLink};
use crate::ui::image::{MediaViewer, ImageViewer};
use anyhow::Result; use anyhow::Result;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use std::sync::Arc; use std::sync::Arc;
@ -9,6 +11,8 @@ pub enum AppMode {
Compose, Compose,
StatusDetail, StatusDetail,
Notifications, Notifications,
MediaView,
LinksView,
Search, Search,
} }
@ -33,6 +37,9 @@ pub struct App {
pub running: bool, pub running: bool,
pub loading: bool, pub loading: bool,
pub error_message: Option<String>, pub error_message: Option<String>,
pub media_viewer: MediaViewer,
pub current_links: Vec<ExtractedLink>,
pub selected_link_index: usize,
} }
impl App { impl App {
@ -51,6 +58,9 @@ impl App {
running: true, running: true,
loading: false, loading: false,
error_message: None, error_message: None,
media_viewer: MediaViewer::new(),
current_links: Vec::new(),
selected_link_index: 0,
} }
} }
@ -193,6 +203,128 @@ impl App {
}; };
} }
pub fn view_media(&mut self) {
if let Some(status) = self.statuses.get(self.selected_status_index) {
let display_status = if let Some(reblog) = &status.reblog {
reblog.as_ref()
} else {
status
};
if !display_status.media_attachments.is_empty() {
self.media_viewer.show_attachments(display_status.media_attachments.clone());
self.mode = AppMode::MediaView;
} else {
// No media attachments found
self.error_message = Some("No media attachments in this post".to_string());
}
} else {
self.error_message = Some("No post selected".to_string());
}
}
pub fn open_current_media(&mut self) {
if let Some(status) = self.statuses.get(self.selected_status_index) {
let display_status = if let Some(reblog) = &status.reblog {
reblog.as_ref()
} else {
status
};
if !display_status.media_attachments.is_empty() {
if let Some(attachment) = display_status.media_attachments.first() {
if let Err(e) = ImageViewer::open_image_externally(&attachment.url) {
self.error_message = Some(format!("Failed to open media: {}", e));
}
}
} else {
self.error_message = Some("No media in this post".to_string());
}
}
}
pub fn view_links(&mut self) {
if let Some(status) = self.statuses.get(self.selected_status_index) {
let display_status = if let Some(reblog) = &status.reblog {
reblog.as_ref()
} else {
status
};
let parser = ContentParser::new();
let parsed_content = parser.parse_content(&display_status.content);
// Debug: show what we found
if parsed_content.links.is_empty() {
// Debug message showing the raw HTML for troubleshooting
let debug_msg = if display_status.content.contains("<a") {
format!("Found <a> tags but no parsed links. Raw content length: {}", display_status.content.len())
} else {
"No <a> tags found in HTML content".to_string()
};
self.error_message = Some(debug_msg);
} else {
self.current_links = parsed_content.links;
self.selected_link_index = 0;
self.mode = AppMode::LinksView;
}
} else {
self.error_message = Some("No post selected".to_string());
}
}
pub fn open_current_link(&mut self) {
if let Some(link) = self.current_links.get(self.selected_link_index) {
if let Err(e) = ImageViewer::open_image_externally(&link.url) {
self.error_message = Some(format!("Failed to open link: {}", e));
}
}
}
pub fn next_link(&mut self) {
if self.selected_link_index < self.current_links.len().saturating_sub(1) {
self.selected_link_index += 1;
}
}
pub fn previous_link(&mut self) {
if self.selected_link_index > 0 {
self.selected_link_index -= 1;
}
}
pub fn debug_post(&mut self) {
if let Some(status) = self.statuses.get(self.selected_status_index) {
let display_status = if let Some(reblog) = &status.reblog {
reblog.as_ref()
} else {
status
};
// Extract just the link tags for debugging
let content = &display_status.content;
let mut link_snippets = Vec::new();
let mut start = 0;
while let Some(pos) = content[start..].find("<a") {
let actual_pos = start + pos;
let end_tag = content[actual_pos..].find("</a>").map(|i| actual_pos + i + 4).unwrap_or(content.len().min(actual_pos + 200));
let snippet = &content[actual_pos..end_tag];
link_snippets.push(snippet.to_string());
start = end_tag;
if link_snippets.len() >= 3 { break; } // Limit to first 3 links
}
if link_snippets.is_empty() {
self.error_message = Some("No <a> tags found in content".to_string());
} else {
self.error_message = Some(format!("Link tags: {}", link_snippets.join(" | ")));
}
} else {
self.error_message = Some("No post selected".to_string());
}
}
pub fn quit(&mut self) { pub fn quit(&mut self) {
self.running = false; self.running = false;
} }
@ -203,6 +335,8 @@ impl App {
AppMode::Compose => self.handle_compose_key(key), AppMode::Compose => self.handle_compose_key(key),
AppMode::StatusDetail => self.handle_detail_key(key), AppMode::StatusDetail => self.handle_detail_key(key),
AppMode::Notifications => self.handle_notifications_key(key), AppMode::Notifications => self.handle_notifications_key(key),
AppMode::MediaView => self.handle_media_key(key),
AppMode::LinksView => self.handle_links_key(key),
AppMode::Search => self.handle_search_key(key), AppMode::Search => self.handle_search_key(key),
} }
} }
@ -223,6 +357,10 @@ impl App {
} }
(KeyCode::Char('t'), _) => self.next_timeline(), (KeyCode::Char('t'), _) => self.next_timeline(),
(KeyCode::Char('m'), _) => self.mode = AppMode::Notifications, (KeyCode::Char('m'), _) => self.mode = AppMode::Notifications,
(KeyCode::Char('v'), _) => self.view_media(),
(KeyCode::Char('l'), _) => self.view_links(),
(KeyCode::Char('d'), _) => self.debug_post(),
(KeyCode::Char('o'), _) => self.open_current_media(),
(KeyCode::Enter, _) => self.mode = AppMode::StatusDetail, (KeyCode::Enter, _) => self.mode = AppMode::StatusDetail,
_ => {} _ => {}
} }
@ -278,6 +416,60 @@ impl App {
} }
} }
fn handle_media_key(&mut self, key: KeyEvent) {
use crossterm::event::{KeyCode, KeyModifiers};
match (key.code, key.modifiers) {
(KeyCode::Esc, _) | (KeyCode::Char('q'), _) => {
self.media_viewer.close();
self.mode = AppMode::Timeline;
}
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
self.media_viewer.close();
self.mode = AppMode::Timeline;
}
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => {
self.media_viewer.next_attachment();
}
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => {
self.media_viewer.previous_attachment();
}
(KeyCode::Enter, _) | (KeyCode::Char('o'), _) => {
if let Err(e) = self.media_viewer.view_current() {
self.error_message = Some(format!("Failed to open media: {}", e));
}
}
_ => {}
}
}
fn handle_links_key(&mut self, key: KeyEvent) {
use crossterm::event::{KeyCode, KeyModifiers};
match (key.code, key.modifiers) {
(KeyCode::Esc, _) | (KeyCode::Char('q'), _) => {
self.current_links.clear();
self.selected_link_index = 0;
self.mode = AppMode::Timeline;
}
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
self.current_links.clear();
self.selected_link_index = 0;
self.mode = AppMode::Timeline;
}
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => {
self.next_link();
}
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => {
self.previous_link();
}
(KeyCode::Enter, _) | (KeyCode::Char('o'), _) => {
self.open_current_link();
}
_ => {}
}
}
fn handle_search_key(&mut self, key: KeyEvent) { fn handle_search_key(&mut self, key: KeyEvent) {
use crossterm::event::{KeyCode, KeyModifiers}; use crossterm::event::{KeyCode, KeyModifiers};

198
src/ui/content.rs Normal file
View File

@ -0,0 +1,198 @@
use html2text::from_read;
use regex::Regex;
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
use std::io::Cursor;
pub struct ContentParser {
link_regex: Regex,
mention_regex: Regex,
hashtag_regex: Regex,
}
#[derive(Debug, Clone)]
pub struct ParsedContent {
pub lines: Vec<Line<'static>>,
pub links: Vec<ExtractedLink>,
}
#[derive(Debug, Clone)]
pub struct ExtractedLink {
pub url: String,
pub display_text: String,
pub link_type: LinkType,
}
#[derive(Debug, Clone)]
pub enum LinkType {
Regular,
Mention,
Hashtag,
}
impl ContentParser {
pub fn new() -> Self {
Self {
// More flexible regex patterns
link_regex: Regex::new(r#"<a[^>]*href=['"]([^'"]*)['"][^>]*>([^<]*)</a>"#).unwrap(),
mention_regex: Regex::new(r#"<a[^>]*class=['"][^'"]*mention[^'"]*['"][^>]*href=['"]([^'"]*)['"][^>]*>([^<]*)</a>"#).unwrap(),
hashtag_regex: Regex::new(r#"<a[^>]*class=['"][^'"]*hashtag[^'"]*['"][^>]*href=['"]([^'"]*)['"][^>]*>#?([^<]*)</a>"#).unwrap(),
}
}
pub fn parse_content(&self, html: &str) -> ParsedContent {
self.parse_content_with_links(html)
}
pub fn parse_content_simple(&self, html: &str) -> Vec<Line<'static>> {
self.parse_content_with_links(html).lines
}
pub fn parse_content_with_links(&self, html: &str) -> ParsedContent {
let mut content = html.to_string();
let mut extracted_links = Vec::new();
// More flexible regex that handles malformed attributes and various quote styles
let link_regex = Regex::new(r#"<a[^>]*href\s*=\s*["']([^"']+)["'][^>]*>(.*?)</a>"#).unwrap();
for cap in link_regex.captures_iter(html) {
if let (Some(url_match), Some(text_match)) = (cap.get(1), cap.get(2)) {
let url = url_match.as_str().to_string();
let text = text_match.as_str().to_string();
let full_match = cap.get(0).unwrap().as_str();
// Determine link type based on class attributes or content
let link_type = if full_match.contains("class=") {
if full_match.contains("mention") {
LinkType::Mention
} else if full_match.contains("hashtag") {
LinkType::Hashtag
} else {
LinkType::Regular
}
} else if text.starts_with('@') {
LinkType::Mention
} else if text.starts_with('#') {
LinkType::Hashtag
} else {
LinkType::Regular
};
let display_text = if text.trim().is_empty() {
Self::shorten_url(&url)
} else {
text.trim().to_string()
};
// Replace in content for display
let marker = match &link_type {
LinkType::Mention => format!("MENTION:{}", text.trim()),
LinkType::Hashtag => format!("HASHTAG:{}", text.trim()),
LinkType::Regular => format!("LINK:{}", if text.trim().is_empty() { Self::shorten_url(&url) } else { text.trim().to_string() }),
};
extracted_links.push(ExtractedLink {
url,
display_text,
link_type,
});
content = content.replace(full_match, &marker);
}
}
// Convert remaining HTML to plain text
let plain_text = match from_read(Cursor::new(content.as_bytes()), 80) {
Ok(text) => text,
Err(_) => content, // Fallback to original if parsing fails
};
// Split into lines and apply styling
let lines: Vec<Line<'static>> = plain_text
.lines()
.map(|line| self.style_line(line))
.collect();
ParsedContent {
lines,
links: extracted_links,
}
}
fn style_line(&self, line: &str) -> Line<'static> {
let mut spans = Vec::new();
let mut current_pos = 0;
// Find all special markers
let markers = [
("MENTION:", Color::Blue),
("HASHTAG:", Color::Cyan),
("LINK:", Color::Yellow),
];
for &(marker, color) in &markers {
while let Some(pos) = line[current_pos..].find(marker) {
let actual_pos = current_pos + pos;
// Add text before the marker
if actual_pos > current_pos {
spans.push(Span::raw(line[current_pos..actual_pos].to_string()));
}
// Find the end of the marked text (next space or end of line)
let start = actual_pos + marker.len();
let end = line[start..]
.find(' ')
.map(|i| start + i)
.unwrap_or(line.len());
let marked_text = line[start..end].to_string();
spans.push(Span::styled(
marked_text,
Style::default()
.fg(color)
.add_modifier(Modifier::UNDERLINED)
.add_modifier(Modifier::BOLD),
));
current_pos = end;
}
}
// Add any remaining text
if current_pos < line.len() {
spans.push(Span::raw(line[current_pos..].to_string()));
}
// If no special content was found, just return the whole line
if spans.is_empty() {
Line::from(Span::raw(line.to_string()))
} else {
Line::from(spans)
}
}
fn shorten_url(url: &str) -> String {
if url.len() <= 40 {
return url.to_string();
}
// Try to extract domain for better readability
if let Ok(parsed_url) = url::Url::parse(url) {
if let Some(domain) = parsed_url.domain() {
if domain.len() < 30 {
return format!("{}...", domain);
}
}
}
format!("{}...", &url[..37])
}
}
impl Default for ContentParser {
fn default() -> Self {
Self::new()
}
}

View File

@ -1,4 +1,6 @@
use crate::ui::app::App; use crate::ui::app::App;
use crate::ui::content::ContentParser;
use crate::ui::image::ImageViewer;
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
@ -70,30 +72,20 @@ fn render_detail_content(f: &mut Frame, app: &App, area: Rect) {
lines.push(Line::from("")); lines.push(Line::from(""));
let content = strip_html(&display_status.content); // Parse content with improved HTML handling
for line in content.lines() { let parser = ContentParser::new();
lines.push(Line::from(Span::raw(line))); let content_lines = parser.parse_content_simple(&display_status.content);
} lines.extend(content_lines);
if !display_status.media_attachments.is_empty() { if !display_status.media_attachments.is_empty() {
lines.push(Line::from("")); lines.push(Line::from(""));
lines.push(Line::from(Span::styled( let media_info = ImageViewer::show_media_attachments(&display_status.media_attachments);
"Media Attachments:", for media_line in media_info.lines() {
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD), if !media_line.trim().is_empty() {
))); lines.push(Line::from(Span::styled(
media_line.to_string(),
for (i, attachment) in display_status.media_attachments.iter().enumerate() { Style::default().fg(Color::Magenta),
lines.push(Line::from(vec![ )));
Span::styled(format!("{}. ", i + 1), Style::default().fg(Color::Gray)),
Span::raw(format!("{:?}: ", attachment.media_type)),
Span::styled(&attachment.url, Style::default().fg(Color::Blue)),
]));
if let Some(description) = &attachment.description {
lines.push(Line::from(vec![
Span::raw(" Alt text: "),
Span::styled(description, Style::default().fg(Color::Gray)),
]));
} }
} }
} }

169
src/ui/image.rs Normal file
View File

@ -0,0 +1,169 @@
use crate::api::MediaAttachment;
use anyhow::Result;
use std::process::Command;
pub struct ImageViewer;
impl ImageViewer {
pub fn can_display_images() -> bool {
// Check if we're in a terminal that supports images (kitty, iTerm2, etc.)
std::env::var("TERM_PROGRAM").map_or(false, |term| {
matches!(term.as_str(), "iTerm.app" | "WezTerm")
}) || std::env::var("KITTY_WINDOW_ID").is_ok()
}
pub fn display_image_inline(url: &str) -> Result<()> {
// For now, always open externally since inline display has compatibility issues
Self::open_image_externally(url)
}
pub fn open_image_externally(url: &str) -> Result<()> {
// Try to open with system default image viewer
let result = if cfg!(target_os = "macos") {
Command::new("open").arg(url).spawn()
} else if cfg!(target_os = "windows") {
Command::new("cmd").args(["/c", "start", "", url]).spawn()
} else {
// Linux/Unix
Command::new("xdg-open").arg(url).spawn()
};
match result {
Ok(mut child) => {
// Don't wait for the process to finish, just let it run
let _ = child.wait();
Ok(())
}
Err(e) => {
// Fallback: try other common browsers/viewers
if cfg!(target_os = "linux") {
// Try firefox as fallback
if let Ok(mut child) = Command::new("firefox").arg(url).spawn() {
let _ = child.wait();
return Ok(());
}
// Try chromium as fallback
if let Ok(mut child) = Command::new("chromium").arg(url).spawn() {
let _ = child.wait();
return Ok(());
}
}
Err(anyhow::anyhow!("Failed to open URL: {}", e))
}
}
}
pub fn show_media_attachments(attachments: &[MediaAttachment]) -> String {
if attachments.is_empty() {
return String::new();
}
let mut result = String::new();
result.push_str("📎 Media:\n");
for (i, attachment) in attachments.iter().enumerate() {
let media_type = match attachment.media_type {
crate::api::MediaType::Image => "🖼️ Image",
crate::api::MediaType::Video => "🎥 Video",
crate::api::MediaType::Audio => "🔊 Audio",
crate::api::MediaType::Gifv => "🎞️ GIF",
crate::api::MediaType::Unknown => "📎 File",
};
result.push_str(&format!(" {}. {}", i + 1, media_type));
if let Some(description) = &attachment.description {
result.push_str(&format!(" - {}", description));
}
result.push('\n');
result.push_str(&format!(" {}", Self::shorten_url(&attachment.url)));
result.push('\n');
}
result
}
fn shorten_url(url: &str) -> String {
if url.len() <= 60 {
return url.to_string();
}
format!("{}...", &url[..57])
}
}
// Media viewing mode for the app
#[derive(Debug, Clone, PartialEq)]
pub enum MediaViewMode {
None,
List,
Viewing { index: usize },
}
pub struct MediaViewer {
pub mode: MediaViewMode,
pub attachments: Vec<MediaAttachment>,
pub selected_index: usize,
}
impl MediaViewer {
pub fn new() -> Self {
Self {
mode: MediaViewMode::None,
attachments: Vec::new(),
selected_index: 0,
}
}
pub fn show_attachments(&mut self, attachments: Vec<MediaAttachment>) {
self.attachments = attachments;
self.selected_index = 0;
self.mode = if self.attachments.is_empty() {
MediaViewMode::None
} else {
MediaViewMode::List
};
}
pub fn view_current(&mut self) -> Result<()> {
if let Some(attachment) = self.attachments.get(self.selected_index) {
self.mode = MediaViewMode::Viewing {
index: self.selected_index,
};
match attachment.media_type {
crate::api::MediaType::Image | crate::api::MediaType::Gifv => {
ImageViewer::display_image_inline(&attachment.url)?;
}
_ => {
ImageViewer::open_image_externally(&attachment.url)?;
}
}
}
Ok(())
}
pub fn next_attachment(&mut self) {
if self.selected_index < self.attachments.len().saturating_sub(1) {
self.selected_index += 1;
}
}
pub fn previous_attachment(&mut self) {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
pub fn close(&mut self) {
self.mode = MediaViewMode::None;
self.attachments.clear();
self.selected_index = 0;
}
}
impl Default for MediaViewer {
fn default() -> Self {
Self::new()
}
}

View File

@ -1,6 +1,8 @@
pub mod app; pub mod app;
pub mod compose; pub mod compose;
pub mod content;
pub mod detail; pub mod detail;
pub mod image;
pub mod timeline; pub mod timeline;
use crate::ui::app::{App, AppMode}; use crate::ui::app::{App, AppMode};
@ -15,7 +17,7 @@ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style}, style::{Color, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, Paragraph}, widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal, Frame, Terminal,
}; };
use std::io; use std::io;
@ -109,6 +111,12 @@ fn ui(f: &mut Frame, app: &App) {
AppMode::Notifications => { AppMode::Notifications => {
render_notifications(f, app, size); render_notifications(f, app, size);
} }
AppMode::MediaView => {
render_media_view(f, app, size);
}
AppMode::LinksView => {
render_links_view(f, app, size);
}
AppMode::Search => { AppMode::Search => {
render_search(f, app, size); render_search(f, app, size);
} }
@ -130,6 +138,12 @@ fn render_help(f: &mut Frame, area: Rect) {
Span::raw(" timeline • "), Span::raw(" timeline • "),
Span::styled("Enter", Style::default().fg(Color::Cyan)), Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(" detail • "), 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::styled("Ctrl+f", Style::default().fg(Color::Cyan)),
Span::raw(" fav • "), Span::raw(" fav • "),
Span::styled("Ctrl+b", Style::default().fg(Color::Cyan)), Span::styled("Ctrl+b", Style::default().fg(Color::Cyan)),
@ -190,6 +204,141 @@ fn render_notifications(f: &mut Frame, app: &App, area: Rect) {
} }
} }
fn render_media_view(f: &mut Frame, app: &App, area: Rect) {
use crate::ui::image::{MediaViewMode, ImageViewer};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)].as_ref())
.split(area);
// Header
let header = Paragraph::new("Media Viewer")
.block(Block::default().borders(Borders::ALL).title("Mastui"))
.style(Style::default().fg(Color::Magenta));
f.render_widget(header, chunks[0]);
// Content
match &app.media_viewer.mode {
MediaViewMode::None => {
let paragraph = Paragraph::new("No media to display")
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Yellow));
f.render_widget(paragraph, chunks[1]);
}
MediaViewMode::List => {
let media_info = ImageViewer::show_media_attachments(&app.media_viewer.attachments);
let mut content = format!("Selected: {} of {}\n\n",
app.media_viewer.selected_index + 1,
app.media_viewer.attachments.len()
);
content.push_str(&media_info);
let paragraph = Paragraph::new(content)
.block(Block::default().borders(Borders::ALL))
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::Magenta));
f.render_widget(paragraph, chunks[1]);
}
MediaViewMode::Viewing { index } => {
let content = format!("Viewing media {} of {} (opened externally)",
index + 1,
app.media_viewer.attachments.len()
);
let paragraph = Paragraph::new(content)
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Green));
f.render_widget(paragraph, chunks[1]);
}
}
// 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("Enter/o", Style::default().fg(Color::Cyan)),
Span::raw(" open • "),
Span::styled("Escape/q", Style::default().fg(Color::Cyan)),
Span::raw(" close"),
]),
];
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 render_links_view(f: &mut Frame, app: &App, area: Rect) {
use crate::ui::content::LinkType;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)].as_ref())
.split(area);
// Header
let header = Paragraph::new("Links in Post")
.block(Block::default().borders(Borders::ALL).title("Mastui"))
.style(Style::default().fg(Color::Yellow));
f.render_widget(header, chunks[0]);
// Content
if app.current_links.is_empty() {
let paragraph = Paragraph::new("No links to display")
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Yellow));
f.render_widget(paragraph, chunks[1]);
} else {
let mut content = format!("Selected: {} of {}\n\n",
app.selected_link_index + 1,
app.current_links.len()
);
for (i, link) in app.current_links.iter().enumerate() {
let prefix = if i == app.selected_link_index { "" } else { " " };
let link_type_icon = match link.link_type {
LinkType::Regular => "🔗",
LinkType::Mention => "👤",
LinkType::Hashtag => "#️⃣",
};
content.push_str(&format!("{}{}. {} {}\n",
prefix, i + 1, link_type_icon, link.display_text));
content.push_str(&format!(" {}\n\n", link.url));
}
let paragraph = Paragraph::new(content)
.block(Block::default().borders(Borders::ALL))
.wrap(Wrap { trim: true })
.style(Style::default().fg(Color::Yellow));
f.render_widget(paragraph, chunks[1]);
}
// 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("Enter/o", Style::default().fg(Color::Cyan)),
Span::raw(" open • "),
Span::styled("Escape/q", Style::default().fg(Color::Cyan)),
Span::raw(" close"),
]),
];
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 render_search(f: &mut Frame, _app: &App, _area: Rect) { fn render_search(f: &mut Frame, _app: &App, _area: Rect) {
let paragraph = Paragraph::new("Search functionality coming soon!") let paragraph = Paragraph::new("Search functionality coming soon!")
.block(Block::default().borders(Borders::ALL).title("Search")) .block(Block::default().borders(Borders::ALL).title("Search"))

View File

@ -1,5 +1,7 @@
use crate::api::Status; use crate::api::Status;
use crate::ui::app::{App, TimelineType}; use crate::ui::app::{App, TimelineType};
use crate::ui::content::ContentParser;
use crate::ui::image::ImageViewer;
use ratatui::{ use ratatui::{
layout::{Constraint, Direction, Layout, Rect}, layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
@ -9,13 +11,27 @@ use ratatui::{
}; };
pub fn render_timeline(f: &mut Frame, app: &App, area: Rect) { pub fn render_timeline(f: &mut Frame, app: &App, area: Rect) {
let constraints: &[Constraint] = if app.error_message.is_some() {
&[Constraint::Length(3), Constraint::Min(0), Constraint::Length(3)]
} else {
&[Constraint::Length(3), Constraint::Min(0)]
};
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)].as_ref()) .constraints(constraints)
.split(area); .split(area);
render_header(f, app, chunks[0]); render_header(f, app, chunks[0]);
render_status_list(f, app, chunks[1]); render_status_list(f, app, chunks[1]);
if let Some(error) = &app.error_message {
let error_widget = Paragraph::new(error.clone())
.block(Block::default().borders(Borders::ALL).title("Debug/Error"))
.style(Style::default().fg(Color::Red))
.wrap(Wrap { trim: true });
f.render_widget(error_widget, chunks[2]);
}
} }
fn render_header(f: &mut Frame, app: &App, area: Rect) { fn render_header(f: &mut Frame, app: &App, area: Rect) {
@ -111,20 +127,32 @@ fn format_status(status: &Status, is_selected: bool) -> ListItem {
), ),
])); ]));
let content = strip_html(&display_status.content); // Parse content with improved HTML handling
let content_lines: Vec<String> = textwrap::wrap(&content, 70) let parser = ContentParser::new();
.into_iter() let content_lines = parser.parse_content_simple(&display_status.content);
.map(|cow| cow.into_owned())
.collect();
// Show first 3 lines of content
for line in content_lines.iter().take(3) { for line in content_lines.iter().take(3) {
lines.push(Line::from(Span::raw(line.clone()))); lines.push(line.clone());
} }
if content_lines.len() > 3 { if content_lines.len() > 3 {
lines.push(Line::from(Span::styled("...", Style::default().fg(Color::Gray)))); lines.push(Line::from(Span::styled("...", Style::default().fg(Color::Gray))));
} }
// Show media attachments with better formatting
if !display_status.media_attachments.is_empty() {
let media_info = ImageViewer::show_media_attachments(&display_status.media_attachments);
for media_line in media_info.lines() {
if !media_line.trim().is_empty() {
lines.push(Line::from(Span::styled(
media_line.to_string(),
Style::default().fg(Color::Magenta),
)));
}
}
}
let mut stats = Vec::new(); let mut stats = Vec::new();
if display_status.replies_count > 0 { if display_status.replies_count > 0 {
@ -146,13 +174,6 @@ fn format_status(status: &Status, is_selected: bool) -> ListItem {
))); )));
} }
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(""))); lines.push(Line::from(Span::raw("")));
ListItem::new(Text::from(lines)) ListItem::new(Text::from(lines))