488 lines
17 KiB
Rust
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();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
} |