use crate::api::{MastodonClient, Status, Notification}; use crate::ui::content::{ContentParser, ExtractedLink}; use crate::ui::image::{MediaViewer, ImageViewer}; use anyhow::Result; use crossterm::event::KeyEvent; use std::sync::Arc; #[derive(Debug, Clone, PartialEq)] pub enum AppMode { Timeline, Compose, StatusDetail, Notifications, MediaView, LinksView, Search, } #[derive(Debug, Clone, PartialEq)] pub enum TimelineType { Home, Local, Federated, } pub struct App { pub client: Arc, pub mode: AppMode, pub timeline_type: TimelineType, pub statuses: Vec, pub notifications: Vec, pub selected_status_index: usize, pub status_scroll: usize, pub compose_text: String, pub reply_to_id: Option, pub search_query: String, pub running: bool, pub loading: bool, pub error_message: Option, pub media_viewer: MediaViewer, pub current_links: Vec, pub selected_link_index: usize, } impl App { pub fn new(client: MastodonClient) -> Self { Self { client: Arc::new(client), mode: AppMode::Timeline, timeline_type: TimelineType::Home, statuses: Vec::new(), notifications: Vec::new(), selected_status_index: 0, status_scroll: 0, compose_text: String::new(), reply_to_id: None, search_query: String::new(), running: true, loading: false, error_message: None, media_viewer: MediaViewer::new(), current_links: Vec::new(), selected_link_index: 0, } } pub async fn load_timeline(&mut self) -> Result<()> { self.loading = true; self.error_message = None; let result = match self.timeline_type { TimelineType::Home => self.client.get_home_timeline(Some(40)).await, TimelineType::Local => self.client.get_public_timeline(true, Some(40)).await, TimelineType::Federated => self.client.get_public_timeline(false, Some(40)).await, }; match result { Ok(new_statuses) => { self.statuses = new_statuses; self.selected_status_index = 0; self.status_scroll = 0; } Err(e) => { self.error_message = Some(format!("Failed to load timeline: {}", e)); } } self.loading = false; Ok(()) } pub async fn load_notifications(&mut self) -> Result<()> { self.loading = true; self.error_message = None; match self.client.get_notifications(Some(40)).await { Ok(new_notifications) => { self.notifications = new_notifications; } Err(e) => { self.error_message = Some(format!("Failed to load notifications: {}", e)); } } self.loading = false; Ok(()) } pub async fn post_status(&mut self) -> Result<()> { if self.compose_text.trim().is_empty() { return Ok(()); } self.loading = true; self.error_message = None; let result = self .client .post_status(&self.compose_text, self.reply_to_id.as_deref(), None) .await; match result { Ok(_) => { self.compose_text.clear(); self.reply_to_id = None; self.mode = AppMode::Timeline; self.load_timeline().await?; } Err(e) => { self.error_message = Some(format!("Failed to post status: {}", e)); } } self.loading = false; Ok(()) } pub async fn toggle_favourite(&mut self) -> Result<()> { if let Some(status) = self.statuses.get(self.selected_status_index) { let status_id = status.id.clone(); let is_favourited = status.favourited.unwrap_or(false); let result = if is_favourited { self.client.unfavourite_status(&status_id).await } else { self.client.favourite_status(&status_id).await }; if let Ok(updated_status) = result { if let Some(status) = self.statuses.get_mut(self.selected_status_index) { *status = updated_status; } } } Ok(()) } pub async fn toggle_reblog(&mut self) -> Result<()> { if let Some(status) = self.statuses.get(self.selected_status_index) { let status_id = status.id.clone(); let is_reblogged = status.reblogged.unwrap_or(false); let result = if is_reblogged { self.client.unreblog_status(&status_id).await } else { self.client.reblog_status(&status_id).await }; if let Ok(updated_status) = result { if let Some(status) = self.statuses.get_mut(self.selected_status_index) { *status = updated_status; } } } Ok(()) } pub fn start_reply(&mut self) { if let Some(status) = self.statuses.get(self.selected_status_index) { self.reply_to_id = Some(status.id.clone()); self.compose_text = format!("@{} ", status.account.acct); self.mode = AppMode::Compose; } } pub fn next_status(&mut self) { if self.selected_status_index < self.statuses.len().saturating_sub(1) { self.selected_status_index += 1; } } pub fn previous_status(&mut self) { if self.selected_status_index > 0 { self.selected_status_index -= 1; } } pub fn next_timeline(&mut self) { self.timeline_type = match self.timeline_type { TimelineType::Home => TimelineType::Local, TimelineType::Local => TimelineType::Federated, TimelineType::Federated => TimelineType::Home, }; } 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(" tags but no parsed links. Raw content length: {}", display_status.content.len()) } else { "No 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("").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 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) { self.running = false; } pub fn handle_key_event(&mut self, key: KeyEvent) { match self.mode { AppMode::Timeline => self.handle_timeline_key(key), AppMode::Compose => self.handle_compose_key(key), AppMode::StatusDetail => self.handle_detail_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), } } fn handle_timeline_key(&mut self, key: KeyEvent) { use crossterm::event::{KeyCode, KeyModifiers}; match (key.code, key.modifiers) { (KeyCode::Char('q'), _) => self.quit(), (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.quit(), (KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.next_status(), (KeyCode::Char('k'), _) | (KeyCode::Up, _) => self.previous_status(), (KeyCode::Char('r'), _) => self.start_reply(), (KeyCode::Char('n'), _) => { self.compose_text.clear(); self.reply_to_id = None; self.mode = AppMode::Compose; } (KeyCode::Char('t'), _) => self.next_timeline(), (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, _ => {} } } fn handle_compose_key(&mut self, key: KeyEvent) { use crossterm::event::{KeyCode, KeyModifiers}; match (key.code, key.modifiers) { (KeyCode::Esc, _) => { self.mode = AppMode::Timeline; if self.reply_to_id.is_none() { self.compose_text.clear(); } } (KeyCode::Char('c'), KeyModifiers::CONTROL) => { self.mode = AppMode::Timeline; if self.reply_to_id.is_none() { self.compose_text.clear(); } } (KeyCode::Char(c), _) => { self.compose_text.push(c); } (KeyCode::Backspace, _) => { self.compose_text.pop(); } (KeyCode::Enter, KeyModifiers::CONTROL) => { // Post the status - will be handled by the main loop } _ => {} } } fn handle_detail_key(&mut self, key: KeyEvent) { use crossterm::event::{KeyCode, KeyModifiers}; match (key.code, key.modifiers) { (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => self.mode = AppMode::Timeline, (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.mode = AppMode::Timeline, (KeyCode::Char('r'), _) => self.start_reply(), _ => {} } } fn handle_notifications_key(&mut self, key: KeyEvent) { use crossterm::event::{KeyCode, KeyModifiers}; match (key.code, key.modifiers) { (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => self.mode = AppMode::Timeline, (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.mode = AppMode::Timeline, _ => {} } } 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) { use crossterm::event::{KeyCode, KeyModifiers}; match (key.code, key.modifiers) { (KeyCode::Esc, _) => self.mode = AppMode::Timeline, (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.mode = AppMode::Timeline, (KeyCode::Char(c), _) => { self.search_query.push(c); } (KeyCode::Backspace, _) => { self.search_query.pop(); } _ => {} } } }