initial commit
This commit is contained in:
parent
5a2b7d10a0
commit
e7dcba39a6
|
@ -0,0 +1,18 @@
|
||||||
|
# Rust
|
||||||
|
/target/
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Configuration (contains sensitive data)
|
||||||
|
config.json
|
||||||
|
*.log
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,18 @@
|
||||||
|
[package]
|
||||||
|
name = "mastui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ratatui = "0.29"
|
||||||
|
crossterm = "0.28"
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
url = "2.5"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
anyhow = "1.0"
|
||||||
|
clap = { version = "4.0", features = ["derive"] }
|
||||||
|
urlencoding = "2.1"
|
||||||
|
textwrap = "0.16"
|
|
@ -0,0 +1,160 @@
|
||||||
|
# Mastui - A TUI Client for Mastodon
|
||||||
|
|
||||||
|
Mastui is a terminal user interface (TUI) client for Mastodon, built in Rust using the ratatui library. It provides a fast, keyboard-driven way to interact with Mastodon instances directly from your terminal.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🏠 **Timeline Viewing**: Browse Home, Local, and Federated timelines
|
||||||
|
- ✏️ **Post Creation**: Compose new posts and replies
|
||||||
|
- 💬 **Interactive Navigation**: Keyboard shortcuts for efficient navigation
|
||||||
|
- ⭐ **Status Interactions**: Like, boost, and reply to posts
|
||||||
|
- 🔐 **OAuth Authentication**: Secure authentication with Mastodon instances
|
||||||
|
- 📱 **Multiple Views**: Timeline, compose, status detail, and notifications
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Rust 1.70+ (for compiling from source)
|
||||||
|
|
||||||
|
### Building from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/mastui.git
|
||||||
|
cd mastui
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary will be available at `target/release/mastui`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Before using Mastui, you need to authenticate with a Mastodon instance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mastui auth mastodon.social
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `mastodon.social` with your preferred Mastodon instance. This will:
|
||||||
|
|
||||||
|
1. Register Mastui as an application on your instance
|
||||||
|
2. Open a browser window for authorization
|
||||||
|
3. Prompt you to enter the authorization code
|
||||||
|
4. Save your credentials for future use
|
||||||
|
|
||||||
|
### Starting the TUI
|
||||||
|
|
||||||
|
Once authenticated, start the TUI interface:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mastui
|
||||||
|
```
|
||||||
|
|
||||||
|
Or explicitly run the timeline view:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mastui timeline
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command Line Posting
|
||||||
|
|
||||||
|
You can also post directly from the command line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mastui post "Hello from the terminal! #mastui"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
### Timeline View
|
||||||
|
|
||||||
|
- `j` / `↓` - Move down to next status
|
||||||
|
- `k` / `↑` - Move up to previous status
|
||||||
|
- `Enter` - View status details
|
||||||
|
- `r` - Reply to selected status
|
||||||
|
- `n` - Compose new post
|
||||||
|
- `t` - Switch between timelines (Home → Local → Federated)
|
||||||
|
- `m` - View notifications
|
||||||
|
- `Ctrl+f` - Favorite/unfavorite status
|
||||||
|
- `Ctrl+b` - Boost/unboost status
|
||||||
|
- `F5` - Refresh timeline
|
||||||
|
- `q` - Quit application
|
||||||
|
|
||||||
|
### Compose View
|
||||||
|
|
||||||
|
- Type to compose your post
|
||||||
|
- `Ctrl+Enter` - Send post
|
||||||
|
- `Escape` - Cancel and return to timeline
|
||||||
|
|
||||||
|
### Status Detail View
|
||||||
|
|
||||||
|
- `r` - Reply to status
|
||||||
|
- `Escape` / `q` - Return to timeline
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
- `Ctrl+C` - Force quit from any view
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Mastui stores its configuration in `~/.config/mastui/config.json`. This file contains:
|
||||||
|
|
||||||
|
- Instance URL
|
||||||
|
- Client credentials
|
||||||
|
- Access token
|
||||||
|
- User information
|
||||||
|
|
||||||
|
The configuration file is created automatically during the authentication process.
|
||||||
|
|
||||||
|
## Supported Mastodon Features
|
||||||
|
|
||||||
|
- ✅ Timeline viewing (Home, Local, Federated)
|
||||||
|
- ✅ Post creation and replies
|
||||||
|
- ✅ Status interactions (favorite, boost)
|
||||||
|
- ✅ Notification viewing
|
||||||
|
- ✅ Media attachment display (URLs shown)
|
||||||
|
- ✅ Account information display
|
||||||
|
- ⚠️ Image viewing (planned)
|
||||||
|
- ⚠️ Search functionality (planned)
|
||||||
|
- ⚠️ Direct messages (planned)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Mastui is built with:
|
||||||
|
|
||||||
|
- **Rust** - Systems programming language
|
||||||
|
- **ratatui** - Terminal user interface library
|
||||||
|
- **tokio** - Async runtime
|
||||||
|
- **reqwest** - HTTP client for API requests
|
||||||
|
- **serde** - Serialization/deserialization
|
||||||
|
- **crossterm** - Cross-platform terminal manipulation
|
||||||
|
|
||||||
|
The application follows a modular architecture:
|
||||||
|
|
||||||
|
- `api/` - Mastodon API client and data types
|
||||||
|
- `config/` - Configuration management
|
||||||
|
- `ui/` - TUI components and rendering
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
2. Run `cargo build` to compile
|
||||||
|
3. Run `cargo test` to run tests
|
||||||
|
4. Use `cargo run -- auth <instance>` to test authentication
|
||||||
|
5. Use `cargo run` to test the TUI
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- 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
|
|
@ -0,0 +1,284 @@
|
||||||
|
use crate::api::types::*;
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use reqwest::{Client, Response};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MastodonClient {
|
||||||
|
client: Client,
|
||||||
|
instance_url: String,
|
||||||
|
access_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MastodonClient {
|
||||||
|
pub fn new(instance_url: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
instance_url: instance_url.trim_end_matches('/').to_string(),
|
||||||
|
access_token: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_token(instance_url: String, access_token: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
instance_url: instance_url.trim_end_matches('/').to_string(),
|
||||||
|
access_token: Some(access_token),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_url(&self, endpoint: &str) -> String {
|
||||||
|
format!("{}{}", self.instance_url, endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, endpoint: &str) -> Result<Response> {
|
||||||
|
let url = self.build_url(endpoint);
|
||||||
|
let mut req = self.client.get(&url);
|
||||||
|
|
||||||
|
if let Some(token) = &self.access_token {
|
||||||
|
req = req.header("Authorization", format!("Bearer {}", token));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = req.send().await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow!("API request failed: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post(&self, endpoint: &str, data: Option<&Value>) -> Result<Response> {
|
||||||
|
let url = self.build_url(endpoint);
|
||||||
|
let mut req = self.client.post(&url);
|
||||||
|
|
||||||
|
if let Some(token) = &self.access_token {
|
||||||
|
req = req.header("Authorization", format!("Bearer {}", token));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(data) = data {
|
||||||
|
req = req.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = req.send().await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow!("API request failed: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn register_app(
|
||||||
|
&self,
|
||||||
|
app_name: &str,
|
||||||
|
redirect_uris: &str,
|
||||||
|
scopes: &str,
|
||||||
|
website: Option<&str>,
|
||||||
|
) -> Result<AppRegistration> {
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
data.insert("client_name", app_name);
|
||||||
|
data.insert("redirect_uris", redirect_uris);
|
||||||
|
data.insert("scopes", scopes);
|
||||||
|
|
||||||
|
if let Some(website) = website {
|
||||||
|
data.insert("website", website);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(&self.build_url("/api/v1/apps"))
|
||||||
|
.json(&data)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow!("Failed to register app: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let app: AppRegistration = response.json().await?;
|
||||||
|
Ok(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_auth_url(&self, client_id: &str, redirect_uri: &str, scopes: &str) -> Result<String> {
|
||||||
|
let mut url = Url::parse(&format!("{}/oauth/authorize", self.instance_url))?;
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("client_id", client_id)
|
||||||
|
.append_pair("redirect_uri", redirect_uri)
|
||||||
|
.append_pair("response_type", "code")
|
||||||
|
.append_pair("scope", scopes);
|
||||||
|
|
||||||
|
Ok(url.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_access_token(
|
||||||
|
&self,
|
||||||
|
client_id: &str,
|
||||||
|
client_secret: &str,
|
||||||
|
redirect_uri: &str,
|
||||||
|
code: &str,
|
||||||
|
) -> Result<AccessToken> {
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
data.insert("client_id", client_id);
|
||||||
|
data.insert("client_secret", client_secret);
|
||||||
|
data.insert("redirect_uri", redirect_uri);
|
||||||
|
data.insert("grant_type", "authorization_code");
|
||||||
|
data.insert("code", code);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(&self.build_url("/oauth/token"))
|
||||||
|
.form(&data)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(anyhow!("Failed to get access token: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token: AccessToken = response.json().await?;
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn verify_credentials(&self) -> Result<Account> {
|
||||||
|
let response = self.get("/api/v1/accounts/verify_credentials").await?;
|
||||||
|
let account: Account = response.json().await?;
|
||||||
|
Ok(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_home_timeline(&self, limit: Option<u32>) -> Result<Vec<Status>> {
|
||||||
|
let mut endpoint = "/api/v1/timelines/home".to_string();
|
||||||
|
if let Some(limit) = limit {
|
||||||
|
endpoint.push_str(&format!("?limit={}", limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self.get(&endpoint).await?;
|
||||||
|
let statuses: Vec<Status> = response.json().await?;
|
||||||
|
Ok(statuses)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_public_timeline(&self, local: bool, limit: Option<u32>) -> Result<Vec<Status>> {
|
||||||
|
let mut endpoint = "/api/v1/timelines/public".to_string();
|
||||||
|
let mut params = vec![];
|
||||||
|
|
||||||
|
if local {
|
||||||
|
params.push("local=true".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(limit) = limit {
|
||||||
|
params.push(format!("limit={}", limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !params.is_empty() {
|
||||||
|
endpoint.push('?');
|
||||||
|
endpoint.push_str(¶ms.join("&"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self.get(&endpoint).await?;
|
||||||
|
let statuses: Vec<Status> = response.json().await?;
|
||||||
|
Ok(statuses)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_status(&self, id: &str) -> Result<Status> {
|
||||||
|
let endpoint = format!("/api/v1/statuses/{}", id);
|
||||||
|
let response = self.get(&endpoint).await?;
|
||||||
|
let status: Status = response.json().await?;
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_status_context(&self, id: &str) -> Result<Context> {
|
||||||
|
let endpoint = format!("/api/v1/statuses/{}/context", id);
|
||||||
|
let response = self.get(&endpoint).await?;
|
||||||
|
let context: Context = response.json().await?;
|
||||||
|
Ok(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_status(
|
||||||
|
&self,
|
||||||
|
content: &str,
|
||||||
|
in_reply_to_id: Option<&str>,
|
||||||
|
visibility: Option<Visibility>,
|
||||||
|
) -> Result<Status> {
|
||||||
|
let mut data = HashMap::new();
|
||||||
|
data.insert("status", content);
|
||||||
|
|
||||||
|
if let Some(reply_id) = in_reply_to_id {
|
||||||
|
data.insert("in_reply_to_id", reply_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(vis) = visibility {
|
||||||
|
let vis_str = match vis {
|
||||||
|
Visibility::Public => "public",
|
||||||
|
Visibility::Unlisted => "unlisted",
|
||||||
|
Visibility::Private => "private",
|
||||||
|
Visibility::Direct => "direct",
|
||||||
|
};
|
||||||
|
data.insert("visibility", vis_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
let json_data = serde_json::to_value(data)?;
|
||||||
|
let response = self.post("/api/v1/statuses", Some(&json_data)).await?;
|
||||||
|
let status: Status = response.json().await?;
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn favourite_status(&self, id: &str) -> Result<Status> {
|
||||||
|
let endpoint = format!("/api/v1/statuses/{}/favourite", id);
|
||||||
|
let response = self.post(&endpoint, None).await?;
|
||||||
|
let status: Status = response.json().await?;
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unfavourite_status(&self, id: &str) -> Result<Status> {
|
||||||
|
let endpoint = format!("/api/v1/statuses/{}/unfavourite", id);
|
||||||
|
let response = self.post(&endpoint, None).await?;
|
||||||
|
let status: Status = response.json().await?;
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reblog_status(&self, id: &str) -> Result<Status> {
|
||||||
|
let endpoint = format!("/api/v1/statuses/{}/reblog", id);
|
||||||
|
let response = self.post(&endpoint, None).await?;
|
||||||
|
let status: Status = response.json().await?;
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unreblog_status(&self, id: &str) -> Result<Status> {
|
||||||
|
let endpoint = format!("/api/v1/statuses/{}/unreblog", id);
|
||||||
|
let response = self.post(&endpoint, None).await?;
|
||||||
|
let status: Status = response.json().await?;
|
||||||
|
Ok(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_notifications(&self, limit: Option<u32>) -> Result<Vec<Notification>> {
|
||||||
|
let mut endpoint = "/api/v1/notifications".to_string();
|
||||||
|
if let Some(limit) = limit {
|
||||||
|
endpoint.push_str(&format!("?limit={}", limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self.get(&endpoint).await?;
|
||||||
|
let notifications: Vec<Notification> = response.json().await?;
|
||||||
|
Ok(notifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search(&self, query: &str, resolve: bool) -> Result<SearchResults> {
|
||||||
|
let mut endpoint = format!("/api/v2/search?q={}", urlencoding::encode(query));
|
||||||
|
if resolve {
|
||||||
|
endpoint.push_str("&resolve=true");
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self.get(&endpoint).await?;
|
||||||
|
let results: SearchResults = response.json().await?;
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct SearchResults {
|
||||||
|
pub accounts: Vec<Account>,
|
||||||
|
pub statuses: Vec<Status>,
|
||||||
|
pub hashtags: Vec<Tag>,
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod client;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub use client::MastodonClient;
|
||||||
|
pub use types::*;
|
|
@ -0,0 +1,241 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Account {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub acct: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub locked: bool,
|
||||||
|
pub bot: bool,
|
||||||
|
pub discoverable: Option<bool>,
|
||||||
|
pub group: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub note: String,
|
||||||
|
pub url: String,
|
||||||
|
pub avatar: String,
|
||||||
|
pub avatar_static: String,
|
||||||
|
pub header: String,
|
||||||
|
pub header_static: String,
|
||||||
|
pub followers_count: u64,
|
||||||
|
pub following_count: u64,
|
||||||
|
pub statuses_count: u64,
|
||||||
|
pub last_status_at: Option<String>,
|
||||||
|
pub source: Option<AccountSource>,
|
||||||
|
pub emojis: Vec<Emoji>,
|
||||||
|
pub fields: Vec<Field>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct AccountSource {
|
||||||
|
pub privacy: String,
|
||||||
|
pub sensitive: bool,
|
||||||
|
pub language: Option<String>,
|
||||||
|
pub note: String,
|
||||||
|
pub fields: Vec<Field>,
|
||||||
|
pub follow_requests_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Field {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
pub verified_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Status {
|
||||||
|
pub id: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub in_reply_to_id: Option<String>,
|
||||||
|
pub in_reply_to_account_id: Option<String>,
|
||||||
|
pub sensitive: bool,
|
||||||
|
pub spoiler_text: String,
|
||||||
|
pub visibility: Visibility,
|
||||||
|
pub language: Option<String>,
|
||||||
|
pub uri: String,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub replies_count: u64,
|
||||||
|
pub reblogs_count: u64,
|
||||||
|
pub favourites_count: u64,
|
||||||
|
pub edited_at: Option<DateTime<Utc>>,
|
||||||
|
pub favourited: Option<bool>,
|
||||||
|
pub reblogged: Option<bool>,
|
||||||
|
pub muted: Option<bool>,
|
||||||
|
pub bookmarked: Option<bool>,
|
||||||
|
pub content: String,
|
||||||
|
pub reblog: Option<Box<Status>>,
|
||||||
|
pub account: Account,
|
||||||
|
pub media_attachments: Vec<MediaAttachment>,
|
||||||
|
pub mentions: Vec<Mention>,
|
||||||
|
pub tags: Vec<Tag>,
|
||||||
|
pub emojis: Vec<Emoji>,
|
||||||
|
pub card: Option<PreviewCard>,
|
||||||
|
pub poll: Option<Poll>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Visibility {
|
||||||
|
Public,
|
||||||
|
Unlisted,
|
||||||
|
Private,
|
||||||
|
Direct,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct MediaAttachment {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub media_type: MediaType,
|
||||||
|
pub url: String,
|
||||||
|
pub preview_url: String,
|
||||||
|
pub remote_url: Option<String>,
|
||||||
|
pub text_url: Option<String>,
|
||||||
|
pub meta: Option<MediaMeta>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub blurhash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum MediaType {
|
||||||
|
Unknown,
|
||||||
|
Image,
|
||||||
|
Gifv,
|
||||||
|
Video,
|
||||||
|
Audio,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct MediaMeta {
|
||||||
|
pub original: Option<MediaInfo>,
|
||||||
|
pub small: Option<MediaInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct MediaInfo {
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
pub size: Option<String>,
|
||||||
|
pub aspect: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Mention {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub url: String,
|
||||||
|
pub acct: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Tag {
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Emoji {
|
||||||
|
pub shortcode: String,
|
||||||
|
pub url: String,
|
||||||
|
pub static_url: String,
|
||||||
|
pub visible_in_picker: bool,
|
||||||
|
pub category: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct PreviewCard {
|
||||||
|
pub url: String,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub card_type: String,
|
||||||
|
pub author_name: String,
|
||||||
|
pub author_url: String,
|
||||||
|
pub provider_name: String,
|
||||||
|
pub provider_url: String,
|
||||||
|
pub html: String,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub image: Option<String>,
|
||||||
|
pub embed_url: String,
|
||||||
|
pub blurhash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Poll {
|
||||||
|
pub id: String,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
pub expired: bool,
|
||||||
|
pub multiple: bool,
|
||||||
|
pub votes_count: u64,
|
||||||
|
pub voters_count: Option<u64>,
|
||||||
|
pub voted: Option<bool>,
|
||||||
|
pub own_votes: Option<Vec<u32>>,
|
||||||
|
pub options: Vec<PollOption>,
|
||||||
|
pub emojis: Vec<Emoji>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct PollOption {
|
||||||
|
pub title: String,
|
||||||
|
pub votes_count: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Application {
|
||||||
|
pub name: String,
|
||||||
|
pub website: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct AppRegistration {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub website: Option<String>,
|
||||||
|
pub redirect_uri: String,
|
||||||
|
pub client_id: String,
|
||||||
|
pub client_secret: String,
|
||||||
|
pub vapid_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct AccessToken {
|
||||||
|
pub access_token: String,
|
||||||
|
pub token_type: String,
|
||||||
|
pub scope: String,
|
||||||
|
pub created_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Notification {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub notification_type: NotificationType,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub account: Account,
|
||||||
|
pub status: Option<Status>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum NotificationType {
|
||||||
|
Follow,
|
||||||
|
FollowRequest,
|
||||||
|
Mention,
|
||||||
|
Reblog,
|
||||||
|
Favourite,
|
||||||
|
Poll,
|
||||||
|
Status,
|
||||||
|
Update,
|
||||||
|
AdminSignUp,
|
||||||
|
AdminReport,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct Context {
|
||||||
|
pub ancestors: Vec<Status>,
|
||||||
|
pub descendants: Vec<Status>,
|
||||||
|
}
|
|
@ -0,0 +1,77 @@
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::fs;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub instance_url: String,
|
||||||
|
pub client_id: String,
|
||||||
|
pub client_secret: String,
|
||||||
|
pub access_token: Option<String>,
|
||||||
|
pub username: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn new(instance_url: String, client_id: String, client_secret: String) -> Self {
|
||||||
|
Self {
|
||||||
|
instance_url,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
access_token: None,
|
||||||
|
username: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_token(
|
||||||
|
instance_url: String,
|
||||||
|
client_id: String,
|
||||||
|
client_secret: String,
|
||||||
|
access_token: String,
|
||||||
|
username: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
instance_url,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
access_token: Some(access_token),
|
||||||
|
username,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load() -> Result<Self> {
|
||||||
|
let config_path = Self::config_path()?;
|
||||||
|
|
||||||
|
if !config_path.exists() {
|
||||||
|
return Err(anyhow!("Config file not found. Run 'mastui auth' to authenticate."));
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&config_path).await?;
|
||||||
|
let config: Config = serde_json::from_str(&content)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save(&self) -> Result<()> {
|
||||||
|
let config_path = Self::config_path()?;
|
||||||
|
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = serde_json::to_string_pretty(self)?;
|
||||||
|
fs::write(&config_path, content).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> Result<PathBuf> {
|
||||||
|
let home = std::env::var("HOME")
|
||||||
|
.or_else(|_| std::env::var("USERPROFILE"))
|
||||||
|
.map_err(|_| anyhow!("Unable to determine home directory"))?;
|
||||||
|
|
||||||
|
Ok(PathBuf::from(home).join(".config").join("mastui").join("config.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_authenticated(&self) -> bool {
|
||||||
|
self.access_token.is_some()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,178 @@
|
||||||
|
mod api;
|
||||||
|
mod config;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use api::MastodonClient;
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use config::Config;
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use ui::{app::App, run_tui};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "mastui")]
|
||||||
|
#[command(about = "A TUI client for Mastodon")]
|
||||||
|
struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Commands>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
Auth {
|
||||||
|
#[arg(help = "Mastodon instance URL (e.g., mastodon.social)")]
|
||||||
|
instance: String,
|
||||||
|
},
|
||||||
|
Timeline,
|
||||||
|
Post {
|
||||||
|
#[arg(help = "Content of the post")]
|
||||||
|
content: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Some(Commands::Auth { instance }) => {
|
||||||
|
auth_flow(instance).await?;
|
||||||
|
}
|
||||||
|
Some(Commands::Timeline) => {
|
||||||
|
let config = Config::load().await?;
|
||||||
|
if !config.is_authenticated() {
|
||||||
|
eprintln!("Not authenticated. Run 'mastui auth <instance>' first.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = MastodonClient::with_token(
|
||||||
|
config.instance_url,
|
||||||
|
config.access_token.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let app = App::new(client);
|
||||||
|
run_tui(app).await?;
|
||||||
|
}
|
||||||
|
Some(Commands::Post { content }) => {
|
||||||
|
let config = Config::load().await?;
|
||||||
|
if !config.is_authenticated() {
|
||||||
|
eprintln!("Not authenticated. Run 'mastui auth <instance>' first.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = MastodonClient::with_token(
|
||||||
|
config.instance_url,
|
||||||
|
config.access_token.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
match client.post_status(&content, None, None).await {
|
||||||
|
Ok(status) => println!("Posted: {}", status.url.unwrap_or(status.uri)),
|
||||||
|
Err(e) => eprintln!("Failed to post: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Default to timeline view
|
||||||
|
match Config::load().await {
|
||||||
|
Ok(config) => {
|
||||||
|
if !config.is_authenticated() {
|
||||||
|
eprintln!("Not authenticated. Run 'mastui auth <instance>' first.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = MastodonClient::with_token(
|
||||||
|
config.instance_url,
|
||||||
|
config.access_token.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let app = App::new(client);
|
||||||
|
run_tui(app).await?;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!("No configuration found. Run 'mastui auth <instance>' to get started.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn auth_flow(instance: String) -> Result<()> {
|
||||||
|
let instance_url = if instance.starts_with("http") {
|
||||||
|
instance
|
||||||
|
} else {
|
||||||
|
format!("https://{}", instance)
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Authenticating with {}...", instance_url);
|
||||||
|
|
||||||
|
let client = MastodonClient::new(instance_url.clone());
|
||||||
|
|
||||||
|
println!("Registering application...");
|
||||||
|
let app_registration = client
|
||||||
|
.register_app(
|
||||||
|
"Mastui",
|
||||||
|
"urn:ietf:wg:oauth:2.0:oob",
|
||||||
|
"read write follow push",
|
||||||
|
Some("https://github.com/yourusername/mastui"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
println!("Application registered successfully!");
|
||||||
|
|
||||||
|
let auth_url = client.get_auth_url(
|
||||||
|
&app_registration.client_id,
|
||||||
|
"urn:ietf:wg:oauth:2.0:oob",
|
||||||
|
"read write follow push",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
println!("\nPlease visit the following URL to authorize the application:");
|
||||||
|
println!("{}", auth_url);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
print!("Enter the authorization code: ");
|
||||||
|
io::stdout().flush()?;
|
||||||
|
|
||||||
|
let mut code = String::new();
|
||||||
|
io::stdin().read_line(&mut code)?;
|
||||||
|
let code = code.trim();
|
||||||
|
|
||||||
|
if code.is_empty() {
|
||||||
|
return Err(anyhow!("No authorization code provided"));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Exchanging authorization code for access token...");
|
||||||
|
let access_token = client
|
||||||
|
.get_access_token(
|
||||||
|
&app_registration.client_id,
|
||||||
|
&app_registration.client_secret,
|
||||||
|
"urn:ietf:wg:oauth:2.0:oob",
|
||||||
|
code,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let authenticated_client = MastodonClient::with_token(
|
||||||
|
instance_url.clone(),
|
||||||
|
access_token.access_token.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
println!("Verifying credentials...");
|
||||||
|
let account = authenticated_client.verify_credentials().await?;
|
||||||
|
|
||||||
|
let config = Config::with_token(
|
||||||
|
instance_url,
|
||||||
|
app_registration.client_id,
|
||||||
|
app_registration.client_secret,
|
||||||
|
access_token.access_token,
|
||||||
|
Some(account.username.clone()),
|
||||||
|
);
|
||||||
|
|
||||||
|
config.save().await?;
|
||||||
|
|
||||||
|
println!("Authentication successful!");
|
||||||
|
println!("Logged in as: {} (@{})", account.display_name, account.acct);
|
||||||
|
println!();
|
||||||
|
println!("You can now run 'mastui' or 'mastui timeline' to start using the TUI.");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -0,0 +1,296 @@
|
||||||
|
use crate::api::{MastodonClient, Status, Notification};
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum AppMode {
|
||||||
|
Timeline,
|
||||||
|
Compose,
|
||||||
|
StatusDetail,
|
||||||
|
Notifications,
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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::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::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_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();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
use crate::ui::app::App;
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph, Wrap},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn render_compose(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Header
|
||||||
|
Constraint::Min(5), // Compose area
|
||||||
|
Constraint::Length(3), // Instructions
|
||||||
|
].as_ref())
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
render_compose_header(f, app, chunks[0]);
|
||||||
|
render_compose_area(f, app, chunks[1]);
|
||||||
|
render_compose_instructions(f, chunks[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_compose_header(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let title = if app.reply_to_id.is_some() {
|
||||||
|
"Compose Reply"
|
||||||
|
} else {
|
||||||
|
"Compose New Post"
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = Paragraph::new(title)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Mastui"))
|
||||||
|
.style(Style::default().fg(Color::Green));
|
||||||
|
|
||||||
|
f.render_widget(header, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_compose_area(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let char_count = app.compose_text.chars().count();
|
||||||
|
let char_limit = 500; // Mastodon's default character limit
|
||||||
|
|
||||||
|
let char_style = if char_count > char_limit {
|
||||||
|
Style::default().fg(Color::Red)
|
||||||
|
} else if char_count > char_limit - 50 {
|
||||||
|
Style::default().fg(Color::Yellow)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Gray)
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = format!("Content ({}/{})", char_count, char_limit);
|
||||||
|
|
||||||
|
let compose_text = if app.compose_text.is_empty() {
|
||||||
|
"Start typing your post...".to_string()
|
||||||
|
} else {
|
||||||
|
app.compose_text.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let text_style = if app.compose_text.is_empty() {
|
||||||
|
Style::default().fg(Color::Gray)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(compose_text)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(Line::from(vec![
|
||||||
|
Span::styled(title, char_style),
|
||||||
|
]))
|
||||||
|
)
|
||||||
|
.style(text_style)
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
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::raw(" to post • "),
|
||||||
|
Span::styled("Escape", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(" to cancel"),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(instructions)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Instructions"))
|
||||||
|
.style(Style::default().fg(Color::Gray));
|
||||||
|
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
|
@ -0,0 +1,176 @@
|
||||||
|
use crate::ui::app::App;
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span, Text},
|
||||||
|
widgets::{Block, Borders, Paragraph, Wrap},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn render_status_detail(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Header
|
||||||
|
Constraint::Min(0), // Content
|
||||||
|
Constraint::Length(3), // Instructions
|
||||||
|
].as_ref())
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
render_detail_header(f, chunks[0]);
|
||||||
|
render_detail_content(f, app, chunks[1]);
|
||||||
|
render_detail_instructions(f, chunks[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_detail_header(f: &mut Frame, area: Rect) {
|
||||||
|
let header = Paragraph::new("Status Detail")
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Mastui"))
|
||||||
|
.style(Style::default().fg(Color::Cyan));
|
||||||
|
|
||||||
|
f.render_widget(header, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_detail_content(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
if let Some(status) = app.statuses.get(app.selected_status_index) {
|
||||||
|
let display_status = if let Some(reblog) = &status.reblog {
|
||||||
|
reblog.as_ref()
|
||||||
|
} else {
|
||||||
|
status
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
if status.reblog.is_some() {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled("🔄 Reblogged by ", Style::default().fg(Color::Green)),
|
||||||
|
Span::styled(
|
||||||
|
&status.account.display_name,
|
||||||
|
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
&display_status.account.display_name,
|
||||||
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
format!("@{}", display_status.account.acct),
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format_detailed_time(&display_status.created_at),
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
)));
|
||||||
|
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
let content = strip_html(&display_status.content);
|
||||||
|
for line in content.lines() {
|
||||||
|
lines.push(Line::from(Span::raw(line)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !display_status.media_attachments.is_empty() {
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"Media Attachments:",
|
||||||
|
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD),
|
||||||
|
)));
|
||||||
|
|
||||||
|
for (i, attachment) in display_status.media_attachments.iter().enumerate() {
|
||||||
|
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)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
let mut stats = Vec::new();
|
||||||
|
stats.push(format!("💬 {} replies", display_status.replies_count));
|
||||||
|
stats.push(format!("🔄 {} reblogs", display_status.reblogs_count));
|
||||||
|
stats.push(format!("⭐ {} favourites", display_status.favourites_count));
|
||||||
|
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
stats.join(" • "),
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
)));
|
||||||
|
|
||||||
|
if display_status.favourited.unwrap_or(false) {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"⭐ You favourited this post",
|
||||||
|
Style::default().fg(Color::Yellow),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if display_status.reblogged.unwrap_or(false) {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"🔄 You reblogged this post",
|
||||||
|
Style::default().fg(Color::Green),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(Text::from(lines))
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
} else {
|
||||||
|
let paragraph = Paragraph::new("No status selected")
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.style(Style::default().fg(Color::Yellow));
|
||||||
|
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_detail_instructions(f: &mut Frame, area: Rect) {
|
||||||
|
let instructions = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("r", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(" to reply • "),
|
||||||
|
Span::styled("Escape/q", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(" to go back"),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(instructions)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Instructions"))
|
||||||
|
.style(Style::default().fg(Color::Gray));
|
||||||
|
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_html(html: &str) -> String {
|
||||||
|
html.replace("<br>", "\n")
|
||||||
|
.replace("<br/>", "\n")
|
||||||
|
.replace("<br />", "\n")
|
||||||
|
.replace("</p>", "\n\n")
|
||||||
|
.replace("<p>", "")
|
||||||
|
.chars()
|
||||||
|
.filter(|&c| c != '<' && c != '>')
|
||||||
|
.collect::<String>()
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_detailed_time(time: &chrono::DateTime<chrono::Utc>) -> String {
|
||||||
|
time.format("%Y-%m-%d %H:%M:%S UTC").to_string()
|
||||||
|
}
|
|
@ -0,0 +1,214 @@
|
||||||
|
pub mod app;
|
||||||
|
pub mod compose;
|
||||||
|
pub mod detail;
|
||||||
|
pub mod timeline;
|
||||||
|
|
||||||
|
use crate::ui::app::{App, AppMode};
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
backend::{Backend, CrosstermBackend},
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame, Terminal,
|
||||||
|
};
|
||||||
|
use std::io;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub async fn run_tui(mut app: App) -> Result<()> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
app.load_timeline().await?;
|
||||||
|
|
||||||
|
let result = run_app(&mut terminal, &mut app).await;
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
terminal.draw(|f| ui(f, app))?;
|
||||||
|
|
||||||
|
if !app.running {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let event_available = event::poll(Duration::from_millis(100))?;
|
||||||
|
|
||||||
|
if event_available {
|
||||||
|
let event = event::read()?;
|
||||||
|
match event {
|
||||||
|
Event::Key(key) => {
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Enter, KeyModifiers::CONTROL) if app.mode == AppMode::Compose => {
|
||||||
|
app.post_status().await?;
|
||||||
|
}
|
||||||
|
(KeyCode::Char('f'), KeyModifiers::CONTROL) if app.mode == AppMode::Timeline => {
|
||||||
|
app.toggle_favourite().await?;
|
||||||
|
}
|
||||||
|
(KeyCode::Char('b'), KeyModifiers::CONTROL) if app.mode == AppMode::Timeline => {
|
||||||
|
app.toggle_reblog().await?;
|
||||||
|
}
|
||||||
|
(KeyCode::F(5), _) => {
|
||||||
|
if app.mode == AppMode::Timeline {
|
||||||
|
app.load_timeline().await?;
|
||||||
|
} else if app.mode == AppMode::Notifications {
|
||||||
|
app.load_notifications().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
app.handle_key_event(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui(f: &mut Frame, app: &App) {
|
||||||
|
let size = f.area();
|
||||||
|
|
||||||
|
match app.mode {
|
||||||
|
AppMode::Timeline => {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(0), Constraint::Length(3)].as_ref())
|
||||||
|
.split(size);
|
||||||
|
|
||||||
|
timeline::render_timeline(f, app, chunks[0]);
|
||||||
|
render_help(f, chunks[1]);
|
||||||
|
}
|
||||||
|
AppMode::Compose => {
|
||||||
|
compose::render_compose(f, app, size);
|
||||||
|
}
|
||||||
|
AppMode::StatusDetail => {
|
||||||
|
detail::render_status_detail(f, app, size);
|
||||||
|
}
|
||||||
|
AppMode::Notifications => {
|
||||||
|
render_notifications(f, app, size);
|
||||||
|
}
|
||||||
|
AppMode::Search => {
|
||||||
|
render_search(f, app, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_help(f: &mut Frame, area: Rect) {
|
||||||
|
let help_text = 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("n", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(" new post • "),
|
||||||
|
Span::styled("t", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(" timeline • "),
|
||||||
|
Span::styled("Enter", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(" detail • "),
|
||||||
|
Span::styled("Ctrl+f", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(" fav • "),
|
||||||
|
Span::styled("Ctrl+b", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(" boost • "),
|
||||||
|
Span::styled("F5", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(" refresh • "),
|
||||||
|
Span::styled("q", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::raw(" quit"),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(help_text)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Help"))
|
||||||
|
.style(Style::default().fg(Color::Gray));
|
||||||
|
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_notifications(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let header = Paragraph::new("Notifications")
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Mastui"))
|
||||||
|
.style(Style::default().fg(Color::Magenta));
|
||||||
|
|
||||||
|
f.render_widget(header, chunks[0]);
|
||||||
|
|
||||||
|
let notifications = &app.notifications;
|
||||||
|
|
||||||
|
if notifications.is_empty() {
|
||||||
|
let paragraph = Paragraph::new("No notifications")
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.style(Style::default().fg(Color::Yellow));
|
||||||
|
|
||||||
|
f.render_widget(paragraph, chunks[1]);
|
||||||
|
} else {
|
||||||
|
let content = notifications
|
||||||
|
.iter()
|
||||||
|
.map(|notif| {
|
||||||
|
format!(
|
||||||
|
"{:?}: {} ({})",
|
||||||
|
notif.notification_type,
|
||||||
|
notif.account.display_name,
|
||||||
|
format_time(¬if.created_at)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(content)
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.wrap(ratatui::widgets::Wrap { trim: true });
|
||||||
|
|
||||||
|
f.render_widget(paragraph, chunks[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_search(f: &mut Frame, _app: &App, _area: Rect) {
|
||||||
|
let paragraph = Paragraph::new("Search functionality coming soon!")
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Search"))
|
||||||
|
.style(Style::default().fg(Color::Yellow));
|
||||||
|
|
||||||
|
f.render_widget(paragraph, _area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_time(time: &chrono::DateTime<chrono::Utc>) -> String {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let duration = now.signed_duration_since(*time);
|
||||||
|
|
||||||
|
if duration.num_days() > 0 {
|
||||||
|
format!("{}d", duration.num_days())
|
||||||
|
} else if duration.num_hours() > 0 {
|
||||||
|
format!("{}h", duration.num_hours())
|
||||||
|
} else if duration.num_minutes() > 0 {
|
||||||
|
format!("{}m", duration.num_minutes())
|
||||||
|
} else {
|
||||||
|
"now".to_string()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,189 @@
|
||||||
|
use crate::api::Status;
|
||||||
|
use crate::ui::app::{App, TimelineType};
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span, Text},
|
||||||
|
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn render_timeline(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
render_header(f, app, chunks[0]);
|
||||||
|
render_status_list(f, app, chunks[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_header(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let timeline_name = match app.timeline_type {
|
||||||
|
TimelineType::Home => "Home",
|
||||||
|
TimelineType::Local => "Local",
|
||||||
|
TimelineType::Federated => "Federated",
|
||||||
|
};
|
||||||
|
|
||||||
|
let header_text = if app.loading {
|
||||||
|
format!("{} Timeline (Loading...)", timeline_name)
|
||||||
|
} else {
|
||||||
|
format!("{} Timeline", timeline_name)
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = Paragraph::new(header_text)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title("Mastui"))
|
||||||
|
.style(Style::default().fg(Color::Cyan));
|
||||||
|
|
||||||
|
f.render_widget(header, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_status_list(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let statuses = &app.statuses;
|
||||||
|
|
||||||
|
if statuses.is_empty() && !app.loading {
|
||||||
|
let empty_msg = if let Some(error) = &app.error_message {
|
||||||
|
format!("Error: {}", error)
|
||||||
|
} else {
|
||||||
|
"No statuses to display".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(empty_msg)
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.wrap(Wrap { trim: true })
|
||||||
|
.style(Style::default().fg(Color::Yellow));
|
||||||
|
|
||||||
|
f.render_widget(paragraph, area);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = statuses
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, status)| format_status(status, i == app.selected_status_index))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut list_state = ListState::default();
|
||||||
|
list_state.select(Some(app.selected_status_index));
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
|
||||||
|
.highlight_symbol("▶ ");
|
||||||
|
|
||||||
|
f.render_stateful_widget(list, area, &mut list_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_status(status: &Status, is_selected: bool) -> ListItem {
|
||||||
|
let display_status = if let Some(reblog) = &status.reblog {
|
||||||
|
reblog.as_ref()
|
||||||
|
} else {
|
||||||
|
status
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
if status.reblog.is_some() {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled("🔄 ", Style::default().fg(Color::Green)),
|
||||||
|
Span::styled(
|
||||||
|
format!("{} reblogged", status.account.display_name),
|
||||||
|
Style::default().fg(Color::Green),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let username_style = if is_selected {
|
||||||
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Cyan)
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
format!("@{}", display_status.account.acct),
|
||||||
|
username_style,
|
||||||
|
),
|
||||||
|
Span::raw(" • "),
|
||||||
|
Span::styled(
|
||||||
|
format_time(&display_status.created_at),
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let content = strip_html(&display_status.content);
|
||||||
|
let content_lines: Vec<String> = textwrap::wrap(&content, 70)
|
||||||
|
.into_iter()
|
||||||
|
.map(|cow| cow.into_owned())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for line in content_lines.iter().take(3) {
|
||||||
|
lines.push(Line::from(Span::raw(line.clone())));
|
||||||
|
}
|
||||||
|
|
||||||
|
if content_lines.len() > 3 {
|
||||||
|
lines.push(Line::from(Span::styled("...", Style::default().fg(Color::Gray))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stats = Vec::new();
|
||||||
|
|
||||||
|
if display_status.replies_count > 0 {
|
||||||
|
stats.push(format!("💬 {}", display_status.replies_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
if display_status.reblogs_count > 0 {
|
||||||
|
stats.push(format!("🔄 {}", display_status.reblogs_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
if display_status.favourites_count > 0 {
|
||||||
|
stats.push(format!("⭐ {}", display_status.favourites_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !stats.is_empty() {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
stats.join(" • "),
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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("")));
|
||||||
|
|
||||||
|
ListItem::new(Text::from(lines))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_html(html: &str) -> String {
|
||||||
|
html.replace("<br>", "\n")
|
||||||
|
.replace("<br/>", "\n")
|
||||||
|
.replace("<br />", "\n")
|
||||||
|
.replace("</p>", "\n")
|
||||||
|
.chars()
|
||||||
|
.filter(|&c| c != '<' && c != '>')
|
||||||
|
.collect::<String>()
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_time(time: &chrono::DateTime<chrono::Utc>) -> String {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let duration = now.signed_duration_since(*time);
|
||||||
|
|
||||||
|
if duration.num_days() > 0 {
|
||||||
|
format!("{}d", duration.num_days())
|
||||||
|
} else if duration.num_hours() > 0 {
|
||||||
|
format!("{}h", duration.num_hours())
|
||||||
|
} else if duration.num_minutes() > 0 {
|
||||||
|
format!("{}m", duration.num_minutes())
|
||||||
|
} else {
|
||||||
|
"now".to_string()
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue