Mastui/src/ui/app.rs

488 lines
17 KiB
Rust

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<MastodonClient>,
pub mode: AppMode,
pub timeline_type: TimelineType,
pub statuses: Vec<Status>,
pub notifications: Vec<Notification>,
pub selected_status_index: usize,
pub status_scroll: usize,
pub compose_text: String,
pub reply_to_id: Option<String>,
pub search_query: String,
pub running: bool,
pub loading: bool,
pub error_message: Option<String>,
pub media_viewer: MediaViewer,
pub current_links: Vec<ExtractedLink>,
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("<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) {
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();
}
_ => {}
}
}
}