added ability to view images and navigate links in posts
This commit is contained in:
parent
e7dcba39a6
commit
f92e4f3130
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||||
|
|
13
README.md
13
README.md
|
@ -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)
|
||||||
|
|
||||||
|
|
192
src/ui/app.rs
192
src/ui/app.rs
|
@ -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};
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)),
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
151
src/ui/mod.rs
151
src/ui/mod.rs
|
@ -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"))
|
||||||
|
|
|
@ -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))
|
||||||
|
|
Loading…
Reference in New Issue