initial commit

This commit is contained in:
rozodru 2025-07-20 11:53:26 -04:00
parent 5a2b7d10a0
commit e7dcba39a6
14 changed files with 4190 additions and 0 deletions

18
.gitignore vendored Normal file
View File

@ -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

2240
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

18
Cargo.toml Normal file
View File

@ -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"

160
README.md Normal file
View File

@ -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

284
src/api/client.rs Normal file
View File

@ -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(&params.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>,
}

5
src/api/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod client;
pub mod types;
pub use client::MastodonClient;
pub use types::*;

241
src/api/types.rs Normal file
View File

@ -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>,
}

77
src/config/mod.rs Normal file
View File

@ -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()
}
}

178
src/main.rs Normal file
View File

@ -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(())
}

296
src/ui/app.rs Normal file
View File

@ -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();
}
_ => {}
}
}
}

94
src/ui/compose.rs Normal file
View File

@ -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);
}

176
src/ui/detail.rs Normal file
View File

@ -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("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
}
fn format_detailed_time(time: &chrono::DateTime<chrono::Utc>) -> String {
time.format("%Y-%m-%d %H:%M:%S UTC").to_string()
}

214
src/ui/mod.rs Normal file
View File

@ -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(&notif.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()
}
}

189
src/ui/timeline.rs Normal file
View File

@ -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("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
}
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()
}
}