fixed issue with posting and replies
This commit is contained in:
parent
d3b180e1ea
commit
dbe75da579
|
@ -20,7 +20,7 @@ Mastui is a terminal user interface (TUI) client for Mastodon, built in Rust usi
|
|||
### Building from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/mastui.git
|
||||
git clone https://github.com/rozodru/mastui.git
|
||||
cd mastui
|
||||
cargo build --release
|
||||
```
|
||||
|
@ -166,4 +166,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|||
|
||||
- The Mastodon team for creating an amazing decentralized social platform
|
||||
- The ratatui community for the excellent TUI framework
|
||||
- The Rust community for the robust ecosystem
|
||||
- The Rust community for the robust ecosystem
|
||||
|
|
102
src/ui/app.rs
102
src/ui/app.rs
|
@ -51,6 +51,9 @@ pub struct App {
|
|||
pub thread_statuses: Vec<Status>,
|
||||
pub selected_thread_index: usize,
|
||||
pub current_status_id: Option<String>,
|
||||
pub profile_account: Option<Account>,
|
||||
pub profile_statuses: Vec<Status>,
|
||||
pub selected_profile_post_index: usize,
|
||||
pub running: bool,
|
||||
pub loading: bool,
|
||||
pub error_message: Option<String>,
|
||||
|
@ -80,6 +83,9 @@ impl App {
|
|||
thread_statuses: Vec::new(),
|
||||
selected_thread_index: 0,
|
||||
current_status_id: None,
|
||||
profile_account: None,
|
||||
profile_statuses: Vec::new(),
|
||||
selected_profile_post_index: 0,
|
||||
running: true,
|
||||
loading: false,
|
||||
error_message: None,
|
||||
|
@ -487,6 +493,52 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn view_profile(&mut self, account_id: &str) -> Result<()> {
|
||||
self.loading = true;
|
||||
self.error_message = None;
|
||||
|
||||
// Load account info and recent posts in parallel
|
||||
let account_future = self.client.get_account(account_id);
|
||||
let statuses_future = self.client.get_account_statuses(account_id, Some(20));
|
||||
|
||||
match tokio::try_join!(account_future, statuses_future) {
|
||||
Ok((account, statuses)) => {
|
||||
self.profile_account = Some(account);
|
||||
self.profile_statuses = statuses;
|
||||
self.selected_profile_post_index = 0;
|
||||
self.mode = AppMode::ProfileView;
|
||||
}
|
||||
Err(e) => {
|
||||
self.error_message = Some(format!("Failed to load profile: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
self.loading = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn view_profile_from_timeline(&mut self) {
|
||||
if let Some(status) = self.statuses.get(self.selected_status_index) {
|
||||
let account_id = status.account.id.clone();
|
||||
// This will be handled by the main event loop
|
||||
self.error_message = Some(format!("Loading profile for {}", account_id));
|
||||
} else {
|
||||
self.error_message = Some("No post selected".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_profile_post(&mut self) {
|
||||
if self.selected_profile_post_index < self.profile_statuses.len().saturating_sub(1) {
|
||||
self.selected_profile_post_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous_profile_post(&mut self) {
|
||||
if self.selected_profile_post_index > 0 {
|
||||
self.selected_profile_post_index -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_help(&mut self) {
|
||||
self.show_detailed_help = !self.show_detailed_help;
|
||||
}
|
||||
|
@ -505,6 +557,7 @@ impl App {
|
|||
AppMode::LinksView => self.handle_links_key(key),
|
||||
AppMode::Search => self.handle_search_key(key),
|
||||
AppMode::ThreadView => self.handle_thread_key(key),
|
||||
AppMode::ProfileView => self.handle_profile_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -537,6 +590,10 @@ impl App {
|
|||
// View thread (h for "hierarchy" or "history")
|
||||
// Will be handled by main event loop
|
||||
},
|
||||
(KeyCode::Char('p'), _) => {
|
||||
// View profile - will be handled by main event loop
|
||||
self.view_profile_from_timeline();
|
||||
},
|
||||
(KeyCode::F(1), _) => self.toggle_help(),
|
||||
(KeyCode::Enter, _) => self.mode = AppMode::StatusDetail,
|
||||
_ => {}
|
||||
|
@ -565,9 +622,6 @@ impl App {
|
|||
(KeyCode::Backspace, _) => {
|
||||
self.compose_text.pop();
|
||||
}
|
||||
(KeyCode::Enter, KeyModifiers::CONTROL) => {
|
||||
// Post the status - will be handled by the main loop
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
@ -717,4 +771,46 @@ impl App {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_profile_key(&mut self, key: KeyEvent) {
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Esc, _) | (KeyCode::Char('q'), _) => {
|
||||
self.profile_account = None;
|
||||
self.profile_statuses.clear();
|
||||
self.selected_profile_post_index = 0;
|
||||
self.mode = AppMode::Timeline;
|
||||
}
|
||||
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
|
||||
self.profile_account = None;
|
||||
self.profile_statuses.clear();
|
||||
self.selected_profile_post_index = 0;
|
||||
self.mode = AppMode::Timeline;
|
||||
}
|
||||
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => {
|
||||
self.next_profile_post();
|
||||
}
|
||||
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => {
|
||||
self.previous_profile_post();
|
||||
}
|
||||
(KeyCode::Char('r'), _) => {
|
||||
// Reply to selected post
|
||||
if let Some(status) = self.profile_statuses.get(self.selected_profile_post_index) {
|
||||
self.reply_to_id = Some(status.id.clone());
|
||||
self.compose_text = format!("@{} ", status.account.acct);
|
||||
self.mode = AppMode::Compose;
|
||||
}
|
||||
}
|
||||
(KeyCode::Enter, _) => {
|
||||
// View selected post in detail
|
||||
if let Some(status) = self.profile_statuses.get(self.selected_profile_post_index) {
|
||||
self.statuses.insert(0, status.clone());
|
||||
self.selected_status_index = 0;
|
||||
self.mode = AppMode::StatusDetail;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,18 +8,39 @@ use ratatui::{
|
|||
};
|
||||
|
||||
pub fn render_compose(f: &mut Frame, app: &App, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
let constraints: &[Constraint] = if app.error_message.is_some() {
|
||||
&[
|
||||
Constraint::Length(3), // Header
|
||||
Constraint::Min(5), // Compose area
|
||||
Constraint::Length(3), // Debug/Error
|
||||
Constraint::Length(3), // Instructions
|
||||
]
|
||||
} else {
|
||||
&[
|
||||
Constraint::Length(3), // Header
|
||||
Constraint::Min(5), // Compose area
|
||||
Constraint::Length(3), // Instructions
|
||||
].as_ref())
|
||||
]
|
||||
};
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(area);
|
||||
|
||||
render_compose_header(f, app, chunks[0]);
|
||||
render_compose_area(f, app, chunks[1]);
|
||||
render_compose_instructions(f, chunks[2]);
|
||||
|
||||
if let Some(error) = &app.error_message {
|
||||
let error_widget = Paragraph::new(error.clone())
|
||||
.block(Block::default().borders(Borders::ALL).title("Debug"))
|
||||
.style(Style::default().fg(Color::Red))
|
||||
.wrap(Wrap { trim: true });
|
||||
f.render_widget(error_widget, chunks[2]);
|
||||
render_compose_instructions(f, chunks[3]);
|
||||
} else {
|
||||
render_compose_instructions(f, chunks[2]);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_compose_header(f: &mut Frame, app: &App, area: Rect) {
|
||||
|
@ -79,7 +100,7 @@ fn render_compose_area(f: &mut Frame, app: &App, area: Rect) {
|
|||
fn render_compose_instructions(f: &mut Frame, area: Rect) {
|
||||
let instructions = vec![
|
||||
Line::from(vec![
|
||||
Span::styled("Ctrl+Enter", Style::default().fg(Color::Cyan)),
|
||||
Span::styled("Enter", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" to post • "),
|
||||
Span::styled("Escape", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" to cancel"),
|
||||
|
|
209
src/ui/mod.rs
209
src/ui/mod.rs
|
@ -60,7 +60,7 @@ async fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Resul
|
|||
match event {
|
||||
Event::Key(key) => {
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Enter, KeyModifiers::CONTROL) if app.mode == AppMode::Compose => {
|
||||
(KeyCode::Enter, KeyModifiers::NONE) if app.mode == AppMode::Compose => {
|
||||
app.post_status().await?;
|
||||
}
|
||||
(KeyCode::Char('f'), KeyModifiers::CONTROL) if app.mode == AppMode::Timeline => {
|
||||
|
@ -75,6 +75,12 @@ async fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Resul
|
|||
(KeyCode::Char('h'), _) if app.mode == AppMode::Timeline => {
|
||||
app.view_thread().await?;
|
||||
}
|
||||
(KeyCode::Char('p'), _) if app.mode == AppMode::Timeline => {
|
||||
if let Some(status) = app.statuses.get(app.selected_status_index) {
|
||||
let account_id = status.account.id.clone();
|
||||
app.view_profile(&account_id).await?;
|
||||
}
|
||||
}
|
||||
(KeyCode::F(5), _) => {
|
||||
if app.mode == AppMode::Timeline {
|
||||
app.load_timeline().await?;
|
||||
|
@ -130,6 +136,9 @@ fn ui(f: &mut Frame, app: &App) {
|
|||
AppMode::ThreadView => {
|
||||
render_thread_view(f, app, size);
|
||||
}
|
||||
AppMode::ProfileView => {
|
||||
render_profile_view(f, app, size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -167,6 +176,8 @@ fn render_help(f: &mut Frame, area: Rect, app: &App) {
|
|||
Span::raw(" links • "),
|
||||
Span::styled("h", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" thread • "),
|
||||
Span::styled("p", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" profile • "),
|
||||
Span::styled("s", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" search • "),
|
||||
Span::styled("o", Style::default().fg(Color::Cyan)),
|
||||
|
@ -196,6 +207,8 @@ fn render_help(f: &mut Frame, area: Rect, app: &App) {
|
|||
Span::raw(" search • "),
|
||||
Span::styled("h", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" thread • "),
|
||||
Span::styled("p", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" profile • "),
|
||||
Span::styled("F1", Style::default().fg(Color::Gray)),
|
||||
Span::raw(" more • "),
|
||||
Span::styled("q", Style::default().fg(Color::Cyan)),
|
||||
|
@ -690,6 +703,200 @@ fn format_thread_status(status: &crate::api::Status, _index: usize, app: &App) -
|
|||
ratatui::widgets::ListItem::new(ratatui::text::Text::from(lines))
|
||||
}
|
||||
|
||||
fn render_profile_view(f: &mut Frame, app: &App, area: Rect) {
|
||||
use crate::ui::content::ContentParser;
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Header
|
||||
Constraint::Length(7), // Profile info
|
||||
Constraint::Min(0), // Posts
|
||||
Constraint::Length(3), // Instructions
|
||||
].as_ref())
|
||||
.split(area);
|
||||
|
||||
// Header
|
||||
let header_text = if let Some(account) = &app.profile_account {
|
||||
format!("Profile: @{}", account.acct)
|
||||
} else {
|
||||
"Profile View".to_string()
|
||||
};
|
||||
|
||||
let header = Paragraph::new(header_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("Mastui"))
|
||||
.style(Style::default().fg(Color::Magenta));
|
||||
f.render_widget(header, chunks[0]);
|
||||
|
||||
// Profile info
|
||||
if let Some(account) = &app.profile_account {
|
||||
let mut profile_lines = Vec::new();
|
||||
|
||||
// Display name and username
|
||||
profile_lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
&account.display_name,
|
||||
Style::default().fg(Color::Cyan).add_modifier(ratatui::style::Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("@{}", account.acct),
|
||||
Style::default().fg(Color::Gray),
|
||||
),
|
||||
]));
|
||||
|
||||
// Stats
|
||||
profile_lines.push(Line::from(vec![
|
||||
Span::styled("Posts: ", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(account.statuses_count.to_string()),
|
||||
Span::raw(" • "),
|
||||
Span::styled("Following: ", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(account.following_count.to_string()),
|
||||
Span::raw(" • "),
|
||||
Span::styled("Followers: ", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(account.followers_count.to_string()),
|
||||
]));
|
||||
|
||||
// Bio (if present)
|
||||
if !account.note.is_empty() {
|
||||
profile_lines.push(Line::from(""));
|
||||
let parser = ContentParser::new();
|
||||
let bio_lines = parser.parse_content_simple(&account.note);
|
||||
for line in bio_lines.iter().take(3) {
|
||||
// Convert to owned spans
|
||||
let owned_spans: Vec<_> = line.spans.iter()
|
||||
.map(|span| Span::styled(span.content.to_string(), span.style))
|
||||
.collect();
|
||||
profile_lines.push(Line::from(owned_spans));
|
||||
}
|
||||
}
|
||||
|
||||
let profile_widget = Paragraph::new(ratatui::text::Text::from(profile_lines))
|
||||
.block(Block::default().borders(Borders::ALL).title("Profile Info"))
|
||||
.wrap(Wrap { trim: true });
|
||||
f.render_widget(profile_widget, chunks[1]);
|
||||
} else {
|
||||
let loading_msg = if app.loading {
|
||||
"Loading profile..."
|
||||
} else {
|
||||
"No profile data"
|
||||
};
|
||||
|
||||
let placeholder = Paragraph::new(loading_msg)
|
||||
.block(Block::default().borders(Borders::ALL).title("Profile Info"))
|
||||
.style(Style::default().fg(Color::Yellow));
|
||||
f.render_widget(placeholder, chunks[1]);
|
||||
}
|
||||
|
||||
// Posts
|
||||
if app.profile_statuses.is_empty() {
|
||||
let empty_msg = if app.loading {
|
||||
"Loading posts..."
|
||||
} else {
|
||||
"No posts found"
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(empty_msg)
|
||||
.block(Block::default().borders(Borders::ALL).title("Recent Posts"))
|
||||
.style(Style::default().fg(Color::Yellow));
|
||||
f.render_widget(paragraph, chunks[2]);
|
||||
} else {
|
||||
let items: Vec<ratatui::widgets::ListItem> = app.profile_statuses
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, status)| format_profile_post(status, i == app.selected_profile_post_index))
|
||||
.collect();
|
||||
|
||||
let mut list_state = ratatui::widgets::ListState::default();
|
||||
list_state.select(Some(app.selected_profile_post_index));
|
||||
|
||||
let list = ratatui::widgets::List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title("Recent Posts"))
|
||||
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(ratatui::style::Modifier::BOLD))
|
||||
.highlight_symbol("▶ ");
|
||||
|
||||
f.render_stateful_widget(list, chunks[2], &mut list_state);
|
||||
}
|
||||
|
||||
// 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("r", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" reply • "),
|
||||
Span::styled("Enter", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" detail • "),
|
||||
Span::styled("Esc/q", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" back"),
|
||||
]),
|
||||
];
|
||||
|
||||
let help = Paragraph::new(instructions)
|
||||
.block(Block::default().borders(Borders::ALL).title("Controls"))
|
||||
.style(Style::default().fg(Color::Gray));
|
||||
f.render_widget(help, chunks[3]);
|
||||
}
|
||||
|
||||
fn format_profile_post(status: &crate::api::Status, is_selected: bool) -> ratatui::widgets::ListItem<'static> {
|
||||
use crate::ui::content::ContentParser;
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Time
|
||||
let time_style = if is_selected {
|
||||
Style::default().fg(Color::Yellow).add_modifier(ratatui::style::Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Gray)
|
||||
};
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
format_time(&status.created_at),
|
||||
time_style,
|
||||
)));
|
||||
|
||||
// Parse and display content
|
||||
let parser = ContentParser::new();
|
||||
let content_lines = parser.parse_content_simple(&status.content);
|
||||
|
||||
// Show first 2 lines of content
|
||||
for line in content_lines.iter().take(2) {
|
||||
let owned_spans: Vec<_> = line.spans.iter()
|
||||
.map(|span| Span::styled(span.content.to_string(), span.style))
|
||||
.collect();
|
||||
lines.push(Line::from(owned_spans));
|
||||
}
|
||||
|
||||
if content_lines.len() > 2 {
|
||||
lines.push(Line::from(Span::styled("...".to_string(), Style::default().fg(Color::Gray))));
|
||||
}
|
||||
|
||||
// Stats
|
||||
if status.replies_count > 0 || status.reblogs_count > 0 || status.favourites_count > 0 {
|
||||
let mut stats = Vec::new();
|
||||
if status.replies_count > 0 {
|
||||
stats.push(format!("💬 {}", status.replies_count));
|
||||
}
|
||||
if status.reblogs_count > 0 {
|
||||
stats.push(format!("🔄 {}", status.reblogs_count));
|
||||
}
|
||||
if status.favourites_count > 0 {
|
||||
stats.push(format!("⭐ {}", status.favourites_count));
|
||||
}
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
stats.join(" • "),
|
||||
Style::default().fg(Color::Gray),
|
||||
)));
|
||||
}
|
||||
|
||||
lines.push(Line::from("".to_string()));
|
||||
|
||||
ratatui::widgets::ListItem::new(ratatui::text::Text::from(lines))
|
||||
}
|
||||
|
||||
fn format_time(time: &chrono::DateTime<chrono::Utc>) -> String {
|
||||
let now = chrono::Utc::now();
|
||||
let duration = now.signed_duration_since(*time);
|
||||
|
|
Loading…
Reference in New Issue