From dc9d8d20d8d59a8553155ed60e996c27b3abc96a Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:34:02 -0500 Subject: [PATCH 01/55] added: rustls to reqwest --- Cargo.toml | 2 +- src/api/auth.rs | 5 ++++- src/app.rs | 2 +- src/ui/error.rs | 18 ++++++++++++------ 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4d58b56..ab224ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ dirs = "6.0.0" keyring-lib = { version = "1.0.3", features = ["tokio"] } ratatui = "0.30.2" -reqwest = { version = "0.13.4", default-features = false, features = ["json"] } +reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] } serde = { version = "1", features = ["derive"] } tokio = { version = "1.52.3", features = ["full"] } serde_json = "1.0.150" diff --git a/src/api/auth.rs b/src/api/auth.rs index 7d4c03a..6be4ff9 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -33,8 +33,11 @@ impl Auth { let err_msg = e.to_string(); if err_msg.contains("401") { "Invalid token. Please check your session token and try again.".to_string() - } else { + } else if err_msg.contains("API GET request") { err_msg + } else { + "Could not connect to the server. Please check your internet connection." + .to_string() } }) } diff --git a/src/app.rs b/src/app.rs index ea1741f..d85723c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -36,7 +36,7 @@ impl App { client = authenticated_client; AppState::LoggedIn } - Err(e) => AppState::Error(format!("Stored token is invalid: {}", e)), + Err(e) => AppState::Error(e), } } else { AppState::InputToken diff --git a/src/ui/error.rs b/src/ui/error.rs index 19b7b7f..430b403 100644 --- a/src/ui/error.rs +++ b/src/ui/error.rs @@ -5,13 +5,19 @@ use ratatui::{ }; pub fn render(f: &mut Frame, message: &str) { - let error_text = format!("CRITICAL SYSTEM ERROR:\n\n{message}\n\nPress any key to return..."); + let title = if message.contains("keyring") || message.contains("Keyring") { + " Keyring Error " + } else if message.contains("connect") || message.contains("internet") { + " Connection Error " + } else if message.contains("Invalid token") { + " Authentication Error " + } else { + " Error " + }; + + let error_text = format!("{message}\n\nPress any key to return..."); let error_msg = Paragraph::new(error_text) .style(Style::default().fg(Color::Red)) - .block( - Block::default() - .title(" Keyring Failure ") - .borders(Borders::ALL), - ); + .block(Block::default().title(title).borders(Borders::ALL)); f.render_widget(error_msg, f.area()); } From a1a3d29be61b52330dec7a6b55ad0ffcb7a7997b Mon Sep 17 00:00:00 2001 From: thairanaru Date: Fri, 17 Jul 2026 09:39:52 -0700 Subject: [PATCH 02/55] test: added tests for typing and single keybinding shortcuts --- src/action.rs | 2 +- src/input.rs | 145 +++++++++++++++++++++++++++++++++++++------------- 2 files changed, 108 insertions(+), 39 deletions(-) diff --git a/src/action.rs b/src/action.rs index 6a5c104..44d4a54 100644 --- a/src/action.rs +++ b/src/action.rs @@ -1,5 +1,5 @@ /// Action based on user input -#[derive(Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum Action { #[allow(unused)] AppendCharacter(char), diff --git a/src/input.rs b/src/input.rs index 6519d23..5fc5e59 100644 --- a/src/input.rs +++ b/src/input.rs @@ -24,6 +24,49 @@ struct KeyMaps { typing: HashMap, Action>, } +impl Default for KeyMaps { + fn default() -> Self { + Self { + ui: HashMap::from([( + vec![KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)], + Action::Quit, + )]), + normal: HashMap::new(), + visual: HashMap::new(), + typing: HashMap::from([ + ( + vec![KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)], + Action::RemoveCharacter, + ), + ( + vec![KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)], + Action::Enter, + ), + ( + vec![KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)], + Action::CursorLeft, + ), + ( + vec![KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)], + Action::CursorRight, + ), + ( + vec![KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)], + Action::CursorUp, + ), + ( + vec![KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)], + Action::CursorDown, + ), + ( + vec![KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)], + Action::Escape, + ), + ]), + } + } +} + pub struct InputState { pub input_mode: InputMode, pending_keys: Vec, @@ -35,49 +78,17 @@ impl Default for InputState { Self { input_mode: InputMode::UI, pending_keys: Vec::with_capacity(2), - key_maps: KeyMaps { - ui: HashMap::from([( - vec![KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)], - Action::Quit, - )]), - normal: HashMap::new(), - visual: HashMap::new(), - typing: HashMap::from([ - ( - vec![KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)], - Action::RemoveCharacter, - ), - ( - vec![KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)], - Action::Enter, - ), - ( - vec![KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)], - Action::CursorLeft, - ), - ( - vec![KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)], - Action::CursorRight, - ), - ( - vec![KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)], - Action::CursorUp, - ), - ( - vec![KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)], - Action::CursorDown, - ), - ( - vec![KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)], - Action::Escape, - ), - ]), - }, + key_maps: KeyMaps::default(), } } } impl InputState { + fn change_input_mode(&mut self, new_mode: InputMode) { + self.pending_keys.clear(); + self.input_mode = new_mode; + } + /// Gets respective keymap based on input mode fn key_map(&self) -> &HashMap, Action> { match self.input_mode { @@ -118,3 +129,61 @@ impl InputState { action } } + +#[cfg(test)] +mod test { + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + use crate::{ + action::Action, + input::{InputMode, InputState}, + }; + + #[test] + fn single_key_motions() { + let mut state = InputState::default(); + for _ in 0..2 { + assert_eq!( + state.process_key_event(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE)), + None, + "Should have no action" + ); + assert_eq!( + state.process_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), + Some(Action::Quit), + "Should have done the quit action" + ); + } + } + + #[test] + fn typing_motions() { + let mut state = InputState::default(); + state.change_input_mode(InputMode::Insert); + assert_eq!( + state.process_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), + Some(Action::AppendCharacter('q')), + "Should have done appened q" + ); + assert_eq!( + state.process_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)), + Some(Action::AppendCharacter('r')), + "Should have appened r" + ); + assert_eq!( + state.process_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::SHIFT)), + Some(Action::AppendCharacter('R')), + "Should have appened R" + ); + assert_eq!( + state.process_key_event(KeyEvent::new(KeyCode::Null, KeyModifiers::NONE)), + None, + "Should have no action" + ); + assert_eq!( + state.process_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), + Some(Action::CursorLeft), + "Should have moved the cursor left" + ); + } +} From 85ea5c54fc31f4acccda245c67553058a343882a Mon Sep 17 00:00:00 2001 From: thairanaru Date: Fri, 17 Jul 2026 09:49:21 -0700 Subject: [PATCH 03/55] fix: update capital letters --- src/input.rs | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/input.rs b/src/input.rs index 5fc5e59..d06e55b 100644 --- a/src/input.rs +++ b/src/input.rs @@ -112,22 +112,28 @@ impl InputState { pub fn process_key_event(&mut self, key_event: KeyEvent) -> Option { self.pending_keys.push(key_event); let key_map = self.key_map(); - - let action = - key_map - .get(&self.pending_keys) - .cloned() - .or(match (self.input_mode, key_event.code) { - (InputMode::Insert | InputMode::Command, KeyCode::Char(c)) => { - Some(Action::AppendCharacter(c)) - } - _ => None, - }); + let action = key_map + .get(&self.pending_keys) + .cloned() + .or(self.handle_typing_event(key_event)); if action.is_some() || !self.has_potential_pending_key_bindings() { self.pending_keys.clear(); } action } + + pub fn handle_typing_event(&self, key_event: KeyEvent) -> Option { + match (self.input_mode, key_event.code) { + (InputMode::Insert | InputMode::Command, KeyCode::Char(c)) => { + match key_event.modifiers { + KeyModifiers::SHIFT => Some(Action::AppendCharacter(c.to_ascii_uppercase())), + KeyModifiers::NONE => Some(Action::AppendCharacter(c)), + _ => None, + } + } + _ => None, + } + } } #[cfg(test)] From 044806a25361c5c4d2ce553577d56d1d6502b7b5 Mon Sep 17 00:00:00 2001 From: thairanaru Date: Fri, 17 Jul 2026 11:21:38 -0700 Subject: [PATCH 04/55] feat: added GoToTop action with tests --- src/action.rs | 2 ++ src/input.rs | 38 ++++++++++++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/action.rs b/src/action.rs index 44d4a54..51c92cc 100644 --- a/src/action.rs +++ b/src/action.rs @@ -1,6 +1,8 @@ /// Action based on user input #[derive(Debug, Clone, Copy, PartialEq)] pub enum Action { + #[allow(unused)] + GoToTopUI, #[allow(unused)] AppendCharacter(char), RemoveCharacter, diff --git a/src/input.rs b/src/input.rs index d06e55b..2eb1d82 100644 --- a/src/input.rs +++ b/src/input.rs @@ -27,10 +27,19 @@ struct KeyMaps { impl Default for KeyMaps { fn default() -> Self { Self { - ui: HashMap::from([( - vec![KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)], - Action::Quit, - )]), + ui: HashMap::from([ + ( + vec![KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)], + Action::Quit, + ), + ( + vec![ + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), + ], + Action::GoToTopUI, + ), + ]), normal: HashMap::new(), visual: HashMap::new(), typing: HashMap::from([ @@ -84,6 +93,7 @@ impl Default for InputState { } impl InputState { + #[allow(unused)] fn change_input_mode(&mut self, new_mode: InputMode) { self.pending_keys.clear(); self.input_mode = new_mode; @@ -192,4 +202,24 @@ mod test { "Should have moved the cursor left" ); } + + #[test] + fn multi_keybinding_shortcuts() { + let mut state = InputState::default(); + assert!( + state.process_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)) + != Some(Action::GoToTopUI), + "Should not called action early" + ); + assert_eq!( + state.process_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)), + Some(Action::GoToTopUI), + "Should have go to top UI after second g" + ); + assert!( + state.process_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE)) + != Some(Action::GoToTopUI), + "Should have cleared pending keys" + ); + } } From c238c046d8135cc20a483763c55ee8d7e08bcd22 Mon Sep 17 00:00:00 2001 From: YetAnotherMechanicusEnjoyer Date: Tue, 21 Jul 2026 22:20:17 +0200 Subject: [PATCH 05/55] fix: changed String error handling to anyhow + thiserror errors --- src/api/auth.rs | 32 ++++++++++++++++++-------------- src/api/client.rs | 1 + src/app.rs | 2 +- src/error.rs | 21 +++++++++++++++++++++ src/ui/render.rs | 2 +- 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/api/auth.rs b/src/api/auth.rs index 6be4ff9..7da093d 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,4 +1,8 @@ -use crate::api::client::{ApiClient, Endpoint}; +use crate::{ + Result, + api::client::{ApiClient, Endpoint}, + error::AuthError, +}; use keyring::KeyringEntry; use serde_json::Value; @@ -7,22 +11,20 @@ pub struct Auth { } impl Auth { - pub fn new() -> Result> { + pub fn new() -> Result { let crate_id = "vimstoat"; let token_entry = KeyringEntry::try_new(crate_id)?; Ok(Self { token_entry }) } - pub async fn store_token(&self, token: &str) -> Result<(), String> { - self.token_entry.set_secret(token).await.map_err(|e| { - format!( - "{}\n\nUnderlying Details:\n{:?}\n\n💡 Hint: If you are on a minimal Linux install, you likely need to install a Secret Service provider (e.g., `sudo pacman -S gnome-keyring`).", - e, e - ) - }) + pub async fn store_token(&self, token: &str) -> Result<()> { + self.token_entry + .set_secret(token) + .await + .map_err(|e| e.into()) } - pub async fn validate_token(&self, token: &str) -> Result { + pub async fn validate_token(&self, token: &str) -> Result { let client = ApiClient::new(token.to_string()); client @@ -32,12 +34,14 @@ impl Auth { .map_err(|e| { let err_msg = e.to_string(); if err_msg.contains("401") { - "Invalid token. Please check your session token and try again.".to_string() + AuthError::InvalidToken( + "Please check your session token and try again.".to_string(), + ) + .into() } else if err_msg.contains("API GET request") { - err_msg + AuthError::RequestError(err_msg).into() } else { - "Could not connect to the server. Please check your internet connection." - .to_string() + AuthError::ServerConnectionError.into() } }) } diff --git a/src/api/client.rs b/src/api/client.rs index adaa6b9..761787f 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -5,6 +5,7 @@ use serde::de::DeserializeOwned; const BASE_URL: &str = "https://api.stoat.chat"; #[derive(Debug)] +#[allow(unused)] pub enum Endpoint { Config, CurrentUser, diff --git a/src/app.rs b/src/app.rs index d85723c..59857dd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,7 +10,7 @@ pub enum AppState { InputToken, ValidatingToken, LoggedIn, - Error(String), + Error(anyhow::Error), } pub struct App { diff --git a/src/error.rs b/src/error.rs index eb5d750..8397af5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -20,3 +20,24 @@ impl From for CacheError { CacheError::DbError(value) } } + +#[derive(Error, Debug)] +pub enum AuthError { + #[error("Keyring Error: {0:?}")] + KeyringError(keyring::Error), + + #[error("Invalid Token: {0}")] + InvalidToken(String), + + #[error("Could not connect to the server. Please check your internet connection.")] + ServerConnectionError, + + #[error("Request Error: {0}")] + RequestError(String), +} + +impl From for AuthError { + fn from(value: keyring::Error) -> Self { + AuthError::KeyringError(value) + } +} diff --git a/src/ui/render.rs b/src/ui/render.rs index c58799c..b56bfb3 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -8,6 +8,6 @@ pub fn render(f: &mut Frame, app: &App) { AppState::InputToken => input_token::render(f, app), AppState::ValidatingToken => validating_token::render(f), AppState::LoggedIn => server_list::render(f, app), - AppState::Error(message) => error::render(f, message), + AppState::Error(message) => error::render(f, &message.to_string()), } } From 14fcdd64d39b73921a3b43a20afca682233aede6 Mon Sep 17 00:00:00 2001 From: YetAnotherMechanicusEnjoyer Date: Fri, 24 Jul 2026 22:52:04 +0200 Subject: [PATCH 06/55] feat(crate): add tokio-tungstenite & futures-util --- Cargo.toml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index ab224ae..9f5ad03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,9 +18,16 @@ pickledb = { version = "0.5.1", features = ["bincode"] } # Directories dirs = "6.0.0" +# WebSockets +tokio-tungstenite = { version = "0.30.0", features = ["native-tls"] } +futures-util = "0.3.31" + keyring-lib = { version = "1.0.3", features = ["tokio"] } ratatui = "0.30.2" -reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] } +reqwest = { version = "0.13.4", default-features = false, features = [ + "json", + "rustls", +] } serde = { version = "1", features = ["derive"] } tokio = { version = "1.52.3", features = ["full"] } serde_json = "1.0.150" From 8ce6b9d8a576c8d9193b90587e4a4842510db9b3 Mon Sep 17 00:00:00 2001 From: YetAnotherMechanicusEnjoyer Date: Fri, 24 Jul 2026 22:52:32 +0200 Subject: [PATCH 07/55] feat: add WebSocket connection to read & send events --- src/api/events.rs | 259 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 src/api/events.rs diff --git a/src/api/events.rs b/src/api/events.rs new file mode 100644 index 0000000..61392e6 --- /dev/null +++ b/src/api/events.rs @@ -0,0 +1,259 @@ +use futures_util::{SinkExt, StreamExt}; +use log::{error, info}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::sync::mpsc; +use tokio_tungstenite::{connect_async, tungstenite::protocol::Message as WsMessage}; + +use crate::{Result, api::WS_BASE_URL}; + +const OUTGOING_BUFFER_SIZE: usize = 32; +const INCOMING_BUFFER_SIZE: usize = 100; + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type")] +#[allow(unused)] +pub enum ClientEvent { + Authenticate { token: String }, + BeginTyping { channel: String }, + EndTyping { channel: String }, + Ping { data: u64 }, + Subscribe { server_id: String }, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(unused)] +pub struct ServerMemberId { + pub server: String, + pub user: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "event_type")] +#[allow(unused)] +pub enum AuthEvent { + DeleteSession { + user_id: String, + session_id: String, + }, + DeleteAllSessions { + user_id: String, + exclude_session_id: Option, + }, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type")] +#[allow(unused)] +pub enum ServerEvent { + Error { + error: String, + }, + Authenticated, + Logout, + Bulk { + v: Vec, + }, + Pong { + data: Value, + }, + Ready { + users: Option>, + servers: Option>, + channels: Option>, + members: Option>, + emojis: Option>, + user_settings: Option>, + channel_unreads: Option>, + policy_changes: Option>, + }, + Message(Value), + MessageUpdate { + id: String, + channel: String, + data: Value, + }, + MessageAppend { + id: String, + channel: String, + append: Value, + }, + MessageDelete { + id: String, + channel: String, + }, + MessageReact { + id: String, + channel_id: String, + user_id: String, + emoji_id: String, + }, + MessageUnreact { + id: String, + channel_id: String, + user_id: String, + emoji_id: String, + }, + MessageRemoveReaction { + id: String, + channel_id: String, + emoji_id: String, + }, + ChannelCreate(Value), + ChannelUpdate { + id: String, + data: Value, + clear: Option>, + }, + ChannelDelete { + id: String, + }, + ChannelGroupJoin { + id: String, + user: String, + }, + ChannelGroupLeave { + id: String, + user: String, + }, + ChannelStartTyping { + id: String, + user: String, + }, + ChannelStopTyping { + id: String, + user: String, + }, + ChannelAck { + id: String, + user: String, + message_id: String, + }, + ServerCreate(Value), + ServerUpdate { + id: String, + data: Value, + clear: Option>, + }, + ServerDelete { + id: String, + }, + ServerMemberUpdate { + id: ServerMemberId, + data: Value, + clear: Option>, + }, + ServerMemberJoin { + id: String, + user: String, + member: Value, + }, + ServerMemberLeave { + id: String, + user: String, + }, + ServerRoleUpdate { + id: String, + role_id: String, + data: Value, + clear: Option>, + }, + ServerRoleDelete { + id: String, + role_id: String, + }, + UserUpdate { + id: String, + data: Value, + clear: Option>, + }, + UserRelationship { + id: String, + user: Value, + status: String, + }, + UserPlatformWipe { + user_id: String, + flags: Value, + }, + EmojiCreate(Value), + EmojiUpdate { + id: String, + data: Value, + }, + EmojiDelete { + id: String, + }, + Auth(AuthEvent), +} + +pub struct WsClient { + tx_outgoing: mpsc::Sender, +} + +impl WsClient { + pub async fn connect(base_url: Option) -> Result<(Self, mpsc::Receiver)> { + let (ws_stream, _) = + connect_async(base_url.unwrap_or(WS_BASE_URL.to_string()).as_str()).await?; + let (mut write, mut read) = ws_stream.split(); + + let (tx_outgoing, mut rx_outgoing) = mpsc::channel::(OUTGOING_BUFFER_SIZE); + let (tx_incoming, rx_incoming) = mpsc::channel::(INCOMING_BUFFER_SIZE); + + tokio::spawn(async move { + while let Some(msg) = read.next().await { + match msg { + Ok(WsMessage::Text(text)) => match serde_json::from_str::(&text) { + Ok(event) => { + Self::dispatch_event(event, &tx_incoming).await; + } + Err(e) => { + error!("Error deserializing ServerEvent: {e}\nBrut data: {text}"); + break; + } + }, + Ok(WsMessage::Close(_)) => { + info!("WS Connexion closed by server."); + break; + } + Err(e) => { + error!("WS Error: {e}"); + break; + } + _ => {} + } + } + }); + + tokio::spawn(async move { + while let Some(event) = rx_outgoing.recv().await { + if let Ok(json) = serde_json::to_string(&event) + && let Err(e) = write.send(WsMessage::Text(json.into())).await + { + error!("Error sending WsMessage: {e}"); + break; + } + } + }); + + Ok((Self { tx_outgoing }, rx_incoming)) + } + + pub async fn send_event(&self, event: ClientEvent) -> Result<()> { + self.tx_outgoing.send(event).await.map_err(|e| e.into()) + } + + pub fn clone_sender(&self) -> mpsc::Sender { + self.tx_outgoing.clone() + } + + pub async fn dispatch_event(event: ServerEvent, tx: &mpsc::Sender) { + if let ServerEvent::Bulk { v } = event { + for sub_event in v { + Box::pin(Self::dispatch_event(sub_event, tx)).await; + } + } else { + tx.send(event).await.ok(); + } + } +} From bbabb8c094450a2311bef69aef501b1b05739c1e Mon Sep 17 00:00:00 2001 From: YetAnotherMechanicusEnjoyer Date: Fri, 24 Jul 2026 22:53:10 +0200 Subject: [PATCH 08/55] feat(refactor): URLs are no longer hard coded --- src/api/auth.rs | 4 ++-- src/api/client.rs | 14 ++++++++++---- src/api/mod.rs | 4 ++++ src/main.rs | 12 +++++++++++- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/api/auth.rs b/src/api/auth.rs index 7da093d..85cda65 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -24,8 +24,8 @@ impl Auth { .map_err(|e| e.into()) } - pub async fn validate_token(&self, token: &str) -> Result { - let client = ApiClient::new(token.to_string()); + pub async fn validate_token(&self, token: &str, base_url: Option) -> Result { + let client = ApiClient::new(token.to_string(), base_url); client .get::(Endpoint::CurrentUser) diff --git a/src/api/client.rs b/src/api/client.rs index 761787f..4754340 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -2,7 +2,7 @@ use anyhow::{Result, anyhow}; use reqwest::Client; use serde::de::DeserializeOwned; -const BASE_URL: &str = "https://api.stoat.chat"; +use crate::api::API_BASE_URL; #[derive(Debug)] #[allow(unused)] @@ -35,20 +35,22 @@ impl Endpoint { pub struct ApiClient { client: Client, token: String, + base_url: String, } impl ApiClient { - pub fn new(token: String) -> Self { + pub fn new(token: String, base_url: Option) -> Self { Self { client: Client::new(), token, + base_url: base_url.unwrap_or(API_BASE_URL.to_string()), } } /// Makes a GET request to the specified endpoint and deserializes the JSON response into `T`. /// The `endpoint` should start with a slash, e.g., `/users/@me`. pub async fn get(&self, endpoint: Endpoint) -> Result { - let url = format!("{}{}", BASE_URL, endpoint.path()); + let url = format!("{}{}", self.base_url, endpoint.path()); let response = self .client @@ -71,6 +73,10 @@ impl ApiClient { )) } } + + pub fn clone_token(&self) -> String { + self.token.clone() + } } #[cfg(test)] @@ -81,7 +87,7 @@ mod tests { #[tokio::test] async fn test_get_config() { // Token doesn't matter for the root config endpoint, but we provide a dummy one - let client = ApiClient::new("dummy_token".to_string()); + let client = ApiClient::new("dummy_token".to_string(), None); let result = client.get::(Endpoint::Config).await; assert!(result.is_ok(), "Failed to get config: {:?}", result.err()); diff --git a/src/api/mod.rs b/src/api/mod.rs index 9ee5abf..45fec5e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,2 +1,6 @@ pub mod auth; pub mod client; +pub mod events; + +pub const API_BASE_URL: &str = "https://api.stoat.chat"; +pub const WS_BASE_URL: &str = "wss://events.stoat.chat"; diff --git a/src/main.rs b/src/main.rs index af3f991..108cc07 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod ui; use std::{fs, path::PathBuf}; use app::App; +use log::debug; use ratatui::crossterm::event::{self, Event}; pub const LOG_FILE: &str = "logs"; @@ -46,7 +47,12 @@ async fn main() -> anyhow::Result<(), Box> { let mut terminal = ratatui::init(); - let mut app = App::new().await?; + let api_base_url = std::env::var("API_BASE_URL").ok(); + let ws_base_url = std::env::var("WS_BASE_URL").ok(); + + let mut app = App::new(api_base_url.clone(), ws_base_url.clone()).await?; + + app.authenticate_ws(&app.api_client.clone_token()).await?; loop { terminal.draw(|f| ui::render(f, &app))?; @@ -62,6 +68,10 @@ async fn main() -> anyhow::Result<(), Box> { break; } } + + if let Some(event) = app.ws_rx.recv().await { + debug!("Received WebSocket event: {event:?}"); + } } ratatui::restore(); From f05bea8635fd93543887e25c2e223d0143011d18 Mon Sep 17 00:00:00 2001 From: YetAnotherMechanicusEnjoyer Date: Fri, 24 Jul 2026 22:53:32 +0200 Subject: [PATCH 09/55] feat: add authenticate_ws to App --- src/app.rs | 104 ++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 91 insertions(+), 13 deletions(-) diff --git a/src/app.rs b/src/app.rs index 59857dd..2516dcd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,10 +1,22 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use log::{debug, error, info, warn}; use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use tokio::sync::mpsc::Receiver; +use tokio::time; -use crate::action::Action; -use crate::api::auth::Auth; -use crate::api::client::ApiClient; -use crate::input::InputState; -use crate::{Result, cache::CacheStore}; +use crate::{ + Result, + action::Action, + api::{ + API_BASE_URL, + auth::Auth, + client::ApiClient, + events::{ClientEvent, ServerEvent, WsClient}, + }, + cache::CacheStore, + input::InputState, +}; pub enum AppState { InputToken, @@ -19,21 +31,24 @@ pub struct App { pub auth: Auth, pub should_quit: bool, pub input_state: InputState, - pub client: ApiClient, + pub api_base_url: String, + pub api_client: ApiClient, + pub ws_client: WsClient, + pub ws_rx: Receiver, #[allow(unused)] pub cache: CacheStore, } impl App { - pub async fn new() -> Result { + pub async fn new(api_base_url: Option, ws_base_url: Option) -> Result { let auth = Auth::new().map_err(|e| anyhow::anyhow!(e))?; - let mut client = ApiClient::new(String::new()); + let mut api_client = ApiClient::new(String::new(), api_base_url.clone()); let state = if let Ok(token) = auth.token_entry.get_secret().await { - match auth.validate_token(&token).await { + match auth.validate_token(&token, api_base_url.clone()).await { Ok(authenticated_client) => { - client = authenticated_client; + api_client = authenticated_client; AppState::LoggedIn } Err(e) => AppState::Error(e), @@ -42,6 +57,8 @@ impl App { AppState::InputToken }; + let (ws_client, ws_rx) = WsClient::connect(ws_base_url).await?; + let cache = CacheStore::new()?; Ok(Self { @@ -49,7 +66,10 @@ impl App { input_text: String::new(), auth, should_quit: false, - client, + api_base_url: api_base_url.unwrap_or(API_BASE_URL.to_string()), + api_client, + ws_client, + ws_rx, cache, input_state: InputState::default(), }) @@ -61,10 +81,14 @@ impl App { KeyCode::Enter => { if !self.input_text.is_empty() { self.state = AppState::ValidatingToken; - match self.auth.validate_token(&self.input_text).await { + match self + .auth + .validate_token(&self.input_text, Some(self.api_base_url.clone())) + .await + { Ok(client) => match self.auth.store_token(&self.input_text).await { Ok(_) => { - self.client = client; + self.api_client = client; self.state = AppState::LoggedIn; } Err(detailed_err) => { @@ -103,4 +127,58 @@ impl App { } Ok(()) } + + pub async fn authenticate_ws(&mut self, token: &str) -> Result<()> { + self.ws_client + .send_event(ClientEvent::Authenticate { + token: token.into(), + }) + .await?; + + let mut is_authenticated = false; + while let Some(event) = self.ws_rx.recv().await { + match event { + ServerEvent::Authenticated => { + info!("Successfully authenticated!"); + is_authenticated = true; + break; + } + ServerEvent::Error { error } => { + error!("Error authenticating: {error}"); + return Ok(()); + } + _ => {} + } + } + + if is_authenticated { + let tx_ping = self.ws_client.clone_sender(); + + tokio::spawn(async move { + let mut interval = time::interval(Duration::from_secs(20)); + + loop { + interval.tick().await; + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + + if tx_ping + .send(ClientEvent::Ping { data: timestamp }) + .await + .is_err() + { + warn!("Stopped pinging: channel closed."); + break; + } + } + }); + + debug!("Started pinging every 20s."); + } + + Ok(()) + } } From b77d171fb86ce5bc3340b171ca51898d98991ebc Mon Sep 17 00:00:00 2001 From: YetAnotherMechanicusEnjoyer Date: Sun, 26 Jul 2026 18:58:44 +0200 Subject: [PATCH 10/55] feat(refactor): WsClient is now in src/api/ws.rs --- src/api/events.rs | 80 -------------------------------------------- src/api/mod.rs | 1 + src/api/ws.rs | 85 +++++++++++++++++++++++++++++++++++++++++++++++ src/app.rs | 3 +- 4 files changed, 88 insertions(+), 81 deletions(-) create mode 100644 src/api/ws.rs diff --git a/src/api/events.rs b/src/api/events.rs index 61392e6..d468bef 100644 --- a/src/api/events.rs +++ b/src/api/events.rs @@ -1,14 +1,5 @@ -use futures_util::{SinkExt, StreamExt}; -use log::{error, info}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use tokio::sync::mpsc; -use tokio_tungstenite::{connect_async, tungstenite::protocol::Message as WsMessage}; - -use crate::{Result, api::WS_BASE_URL}; - -const OUTGOING_BUFFER_SIZE: usize = 32; -const INCOMING_BUFFER_SIZE: usize = 100; #[derive(Debug, Clone, Serialize)] #[serde(tag = "type")] @@ -186,74 +177,3 @@ pub enum ServerEvent { }, Auth(AuthEvent), } - -pub struct WsClient { - tx_outgoing: mpsc::Sender, -} - -impl WsClient { - pub async fn connect(base_url: Option) -> Result<(Self, mpsc::Receiver)> { - let (ws_stream, _) = - connect_async(base_url.unwrap_or(WS_BASE_URL.to_string()).as_str()).await?; - let (mut write, mut read) = ws_stream.split(); - - let (tx_outgoing, mut rx_outgoing) = mpsc::channel::(OUTGOING_BUFFER_SIZE); - let (tx_incoming, rx_incoming) = mpsc::channel::(INCOMING_BUFFER_SIZE); - - tokio::spawn(async move { - while let Some(msg) = read.next().await { - match msg { - Ok(WsMessage::Text(text)) => match serde_json::from_str::(&text) { - Ok(event) => { - Self::dispatch_event(event, &tx_incoming).await; - } - Err(e) => { - error!("Error deserializing ServerEvent: {e}\nBrut data: {text}"); - break; - } - }, - Ok(WsMessage::Close(_)) => { - info!("WS Connexion closed by server."); - break; - } - Err(e) => { - error!("WS Error: {e}"); - break; - } - _ => {} - } - } - }); - - tokio::spawn(async move { - while let Some(event) = rx_outgoing.recv().await { - if let Ok(json) = serde_json::to_string(&event) - && let Err(e) = write.send(WsMessage::Text(json.into())).await - { - error!("Error sending WsMessage: {e}"); - break; - } - } - }); - - Ok((Self { tx_outgoing }, rx_incoming)) - } - - pub async fn send_event(&self, event: ClientEvent) -> Result<()> { - self.tx_outgoing.send(event).await.map_err(|e| e.into()) - } - - pub fn clone_sender(&self) -> mpsc::Sender { - self.tx_outgoing.clone() - } - - pub async fn dispatch_event(event: ServerEvent, tx: &mpsc::Sender) { - if let ServerEvent::Bulk { v } = event { - for sub_event in v { - Box::pin(Self::dispatch_event(sub_event, tx)).await; - } - } else { - tx.send(event).await.ok(); - } - } -} diff --git a/src/api/mod.rs b/src/api/mod.rs index 45fec5e..1c9346f 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod client; pub mod events; +pub mod ws; pub const API_BASE_URL: &str = "https://api.stoat.chat"; pub const WS_BASE_URL: &str = "wss://events.stoat.chat"; diff --git a/src/api/ws.rs b/src/api/ws.rs new file mode 100644 index 0000000..a6064ad --- /dev/null +++ b/src/api/ws.rs @@ -0,0 +1,85 @@ +use crate::{ + Result, + api::{ + WS_BASE_URL, + events::{ClientEvent, ServerEvent}, + }, +}; +use futures_util::{SinkExt, StreamExt}; +use log::{error, info}; +use tokio::sync::mpsc; +use tokio_tungstenite::{connect_async, tungstenite::protocol::Message as WsMessage}; + +const OUTGOING_BUFFER_SIZE: usize = 32; +const INCOMING_BUFFER_SIZE: usize = 100; + +pub struct WsClient { + tx_outgoing: mpsc::Sender, +} + +impl WsClient { + pub async fn connect(base_url: Option) -> Result<(Self, mpsc::Receiver)> { + let (ws_stream, _) = + connect_async(base_url.unwrap_or(WS_BASE_URL.to_string()).as_str()).await?; + let (mut write, mut read) = ws_stream.split(); + + let (tx_outgoing, mut rx_outgoing) = mpsc::channel::(OUTGOING_BUFFER_SIZE); + let (tx_incoming, rx_incoming) = mpsc::channel::(INCOMING_BUFFER_SIZE); + + tokio::spawn(async move { + while let Some(msg) = read.next().await { + match msg { + Ok(WsMessage::Text(text)) => match serde_json::from_str::(&text) { + Ok(event) => { + Self::dispatch_event(event, &tx_incoming).await; + } + Err(e) => { + error!("Error deserializing ServerEvent: {e}\nBrut data: {text}"); + break; + } + }, + Ok(WsMessage::Close(_)) => { + info!("WS Connexion closed by server."); + break; + } + Err(e) => { + error!("WS Error: {e}"); + break; + } + _ => {} + } + } + }); + + tokio::spawn(async move { + while let Some(event) = rx_outgoing.recv().await { + if let Ok(json) = serde_json::to_string(&event) + && let Err(e) = write.send(WsMessage::Text(json.into())).await + { + error!("Error sending WsMessage: {e}"); + break; + } + } + }); + + Ok((Self { tx_outgoing }, rx_incoming)) + } + + pub async fn send_event(&self, event: ClientEvent) -> Result<()> { + self.tx_outgoing.send(event).await.map_err(|e| e.into()) + } + + pub fn clone_sender(&self) -> mpsc::Sender { + self.tx_outgoing.clone() + } + + pub async fn dispatch_event(event: ServerEvent, tx: &mpsc::Sender) { + if let ServerEvent::Bulk { v } = event { + for sub_event in v { + Box::pin(Self::dispatch_event(sub_event, tx)).await; + } + } else { + tx.send(event).await.ok(); + } + } +} diff --git a/src/app.rs b/src/app.rs index 2516dcd..8d6e28a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -12,7 +12,8 @@ use crate::{ API_BASE_URL, auth::Auth, client::ApiClient, - events::{ClientEvent, ServerEvent, WsClient}, + events::{ClientEvent, ServerEvent}, + ws::WsClient, }, cache::CacheStore, input::InputState, From 76f2ef6acab1e5c84700b1d4da83ee9f14830945 Mon Sep 17 00:00:00 2001 From: YetAnotherMechanicusEnjoyer Date: Wed, 29 Jul 2026 23:23:29 +0200 Subject: [PATCH 11/55] feat: add NotifyHandler with notify_rust crate --- Cargo.toml | 8 ++++- src/main.rs | 30 +++++++++++++++++ src/notification.rs | 79 +++++++++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 3 +- 4 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 src/notification.rs diff --git a/Cargo.toml b/Cargo.toml index ab224ae..9dc6055 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,9 +18,15 @@ pickledb = { version = "0.5.1", features = ["bincode"] } # Directories dirs = "6.0.0" +# Notifications +notify-rust = "4.18.0" + keyring-lib = { version = "1.0.3", features = ["tokio"] } ratatui = "0.30.2" -reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] } +reqwest = { version = "0.13.4", default-features = false, features = [ + "json", + "rustls", +] } serde = { version = "1", features = ["derive"] } tokio = { version = "1.52.3", features = ["full"] } serde_json = "1.0.150" diff --git a/src/main.rs b/src/main.rs index af3f991..ab8f2ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,13 +4,17 @@ mod app; mod cache; mod error; mod input; +mod notification; mod ui; use std::{fs, path::PathBuf}; use app::App; +use notify_rust::{CloseReason, NotificationResponse}; use ratatui::crossterm::event::{self, Event}; +use crate::notification::NotifyHandler; + pub const LOG_FILE: &str = "logs"; pub type Result = anyhow::Result; @@ -48,6 +52,32 @@ async fn main() -> anyhow::Result<(), Box> { let mut app = App::new().await?; + /* This is an example, for now we have no use for notifications */ + { + let n = NotifyHandler::new().await?; + let icon = n.clone_icon_path(); + + n.send_notification( + "This is a title".to_string(), + "This is a body".to_string(), + icon, + vec![ + ("reply".to_string(), "Reply".to_string()), + ("read".to_string(), "Mark as read".to_string()), + ], + |response: &NotificationResponse| match response { + NotificationResponse::Default => log::info!("body clicked"), + NotificationResponse::Action(key) => log::info!("button {key:?} clicked"), + NotificationResponse::Reply(text) => log::info!("user replied: {text}"), + NotificationResponse::Closed(CloseReason::Dismissed) => { + log::info!("dismissed by the user") + } + NotificationResponse::Closed(reason) => log::info!("closed: {reason:?}"), + }, + ) + .await; + } + loop { terminal.draw(|f| ui::render(f, &app))?; diff --git a/src/notification.rs b/src/notification.rs new file mode 100644 index 0000000..0d1fcd0 --- /dev/null +++ b/src/notification.rs @@ -0,0 +1,79 @@ +use log::{error, warn}; +use notify_rust::{Notification, ResponseHandler, Timeout}; +use std::{fs, path::PathBuf, thread}; + +use crate::Result; + +pub const ICON_FILE: &str = "icon"; // since idk which file extention we'll be using, I'm leaving it without +pub const NOTIFICATION_TIMEOUT: u32 = 10_000; + +fn get_icon_path() -> Option { + let mut icon_path = dirs::data_dir()?; + icon_path.push(env!("CARGO_PKG_NAME")); + + if let Err(e) = fs::create_dir_all(&icon_path) { + error!("Error creating data directory: {e:?}"); + return None; + }; + + icon_path.push(ICON_FILE); + Some(icon_path) +} + +pub struct NotifyHandler { + icon_path: Option, +} + +impl NotifyHandler { + pub async fn new() -> Result { + let icon_path = get_icon_path(); + if icon_path.is_none() { + warn!("Failed to find icon path!"); + } + + Ok(Self { icon_path }) + } + + pub fn clone_icon_path(&self) -> String { + self.icon_path + .clone() + .unwrap_or_default() + .to_string_lossy() + .to_string() + } + + pub async fn send_notification( + &self, + title: String, + body: String, + icon: String, + actions: Vec<(String, String)>, + response: impl ResponseHandler + Send + 'static, + ) { + thread::spawn(move || { + let mut notification = Notification::new() + .appname(env!("CARGO_PKG_NAME")) + .summary(&title) + .body(&body) + .timeout(Timeout::Milliseconds(NOTIFICATION_TIMEOUT)) + .icon(&icon) + .clone(); + + for action in actions { + notification.action(&action.0, &action.1); + } + + let res = notification.show(); + match res { + Ok(handle) => { + if let Err(e) = handle.wait_for_response(response) { + error!("Error fetching notification response: {e:?}"); + }; + } + Err(e) => { + error!("Error sending notification: {e:?}"); + } + } + }); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index db8968f..4904799 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,8 +1,7 @@ mod error; mod input_token; +mod render; mod server_list; mod validating_token; -mod render; - pub use render::render; From 54ba54862afcc8264c95058f49a1e8b34cb622b0 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:09:42 -0500 Subject: [PATCH 12/55] fix: blocking WebSocket receiver causing unresponsive input --- src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index bc95419..7e87cec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,8 +10,8 @@ mod ui; use std::{fs, path::PathBuf}; use app::App; -use notify_rust::{CloseReason, NotificationResponse}; use log::debug; +use notify_rust::{CloseReason, NotificationResponse}; use ratatui::crossterm::event::{self, Event}; use crate::notification::NotifyHandler; @@ -99,7 +99,7 @@ async fn main() -> anyhow::Result<(), Box> { } } - if let Some(event) = app.ws_rx.recv().await { + if let Ok(event) = app.ws_rx.try_recv() { debug!("Received WebSocket event: {event:?}"); } } From fe8d657913b3bc2befe86c6967921b59c5688284 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:19:40 -0500 Subject: [PATCH 13/55] refactor: handle WebSocket ready event and store servers in memory --- src/api/ws.rs | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/app.rs | 3 ++ src/cache.rs | 11 +++++++ src/main.rs | 2 ++ src/models.rs | 8 +++++ 5 files changed, 113 insertions(+) create mode 100644 src/models.rs diff --git a/src/api/ws.rs b/src/api/ws.rs index a6064ad..2b23540 100644 --- a/src/api/ws.rs +++ b/src/api/ws.rs @@ -4,9 +4,11 @@ use crate::{ WS_BASE_URL, events::{ClientEvent, ServerEvent}, }, + models::Server, }; use futures_util::{SinkExt, StreamExt}; use log::{error, info}; +use serde_json::Value; use tokio::sync::mpsc; use tokio_tungstenite::{connect_async, tungstenite::protocol::Message as WsMessage}; @@ -83,3 +85,90 @@ impl WsClient { } } } + +pub struct EventHandler<'a> { + servers: &'a mut Vec, +} + +impl<'a> EventHandler<'a> { + pub fn new(servers: &'a mut Vec) -> Self { + Self { servers } + } + + pub fn handle_event(&mut self, event: &ServerEvent) { + match event { + ServerEvent::Ready { servers, .. } => { + self.handle_ready(servers.as_deref()); + } + _ => {} + } + } + + fn handle_ready(&mut self, servers: Option<&[Value]>) { + if let Some(servers) = servers { + for server_val in servers { + self.handle_server(server_val); + } + } + } + + fn handle_server(&mut self, server_val: &Value) { + let id = server_val + .get("_id") + .or_else(|| server_val.get("id")) + .and_then(|v| v.as_str()); + let name = server_val.get("name").and_then(|v| v.as_str()); + let description = server_val + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + if let (Some(id_str), Some(name_str)) = (id, name) { + let server = Server { + id: id_str.to_string(), + name: name_str.to_string(), + description, + }; + self.servers.retain(|s| s.id != id_str); + self.servers.push(server); + info!("Stored server in memory: {id_str} => {name_str}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_handle_ready_event_servers_in_memory() { + let mut servers = Vec::new(); + + let server_id = "01KXCTGX37FXG9CASWC35R3S21"; + let server_name = "vimstoat"; + + let ready_event = ServerEvent::Ready { + users: None, + servers: Some(vec![json!({ + "_id": server_id, + "name": server_name, + "description": null + })]), + channels: None, + members: None, + emojis: None, + user_settings: None, + channel_unreads: None, + policy_changes: None, + }; + + let mut handler = EventHandler::new(&mut servers); + handler.handle_event(&ready_event); + + assert_eq!(servers.len(), 1, "Server should be stored in memory"); + assert_eq!(servers[0].id, server_id); + assert_eq!(servers[0].name, server_name); + assert_eq!(servers[0].description, None); + } +} diff --git a/src/app.rs b/src/app.rs index 8d6e28a..07ca41f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,6 +17,7 @@ use crate::{ }, cache::CacheStore, input::InputState, + models::Server, }; pub enum AppState { @@ -38,6 +39,7 @@ pub struct App { pub ws_rx: Receiver, #[allow(unused)] pub cache: CacheStore, + pub servers: Vec, } impl App { @@ -72,6 +74,7 @@ impl App { ws_client, ws_rx, cache, + servers: Vec::new(), input_state: InputState::default(), }) } diff --git a/src/cache.rs b/src/cache.rs index d001c36..7d36b91 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -93,6 +93,17 @@ impl CacheStore { Ok(Self { db, path }) } + #[cfg(test)] + pub fn new_temporary(dir: &std::path::Path) -> Result { + let path = dir.join(DB_FILE); + let db = PickleDb::new( + &path, + pickledb::PickleDbDumpPolicy::AutoDump, + pickledb::SerializationMethod::Bin, + ); + Ok(Self { db, path }) + } + pub fn set(&mut self, id: Id, value: &V) -> Result<()> { let key = Self::build_key::(id)?; diff --git a/src/main.rs b/src/main.rs index 7e87cec..9c16ee6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod app; mod cache; mod error; mod input; +mod models; mod notification; mod ui; @@ -101,6 +102,7 @@ async fn main() -> anyhow::Result<(), Box> { if let Ok(event) = app.ws_rx.try_recv() { debug!("Received WebSocket event: {event:?}"); + api::ws::EventHandler::new(&mut app.servers).handle_event(&event); } } diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..d3339b7 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct Server { + pub id: String, + pub name: String, + pub description: Option, +} From 76cfbbb1633730bc5fc8e0b0d687d0967a260b0a Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:38:55 -0500 Subject: [PATCH 14/55] feat: render dynamic server list with Vim hybrid line numbers --- src/app.rs | 23 ++++++++++-- src/input.rs | 16 ++++++++ src/ui/server_list.rs | 86 +++++++++++++++++++++++++++++++++++++++---- 3 files changed, 115 insertions(+), 10 deletions(-) diff --git a/src/app.rs b/src/app.rs index 07ca41f..45859dc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -40,6 +40,7 @@ pub struct App { #[allow(unused)] pub cache: CacheStore, pub servers: Vec, + pub selected_index: usize, } impl App { @@ -75,6 +76,7 @@ impl App { ws_rx, cache, servers: Vec::new(), + selected_index: 0, input_state: InputState::default(), }) } @@ -119,9 +121,24 @@ impl App { AppState::ValidatingToken => {} AppState::LoggedIn => { let action = self.input_state.process_key_event(key); - if let Some(Action::Quit) = action { - self.should_quit = true; - }; + match action { + Some(Action::Quit) => self.should_quit = true, + Some(Action::CursorUp) => { + if self.selected_index > 0 { + self.selected_index -= 1; + } + } + Some(Action::CursorDown) => { + let total_items = 1 + self.servers.len(); + if total_items > 0 && self.selected_index + 1 < total_items { + self.selected_index += 1; + } + } + Some(Action::GoToTopUI) => { + self.selected_index = 0; + } + _ => {} + } } AppState::Error(_) => { if matches!(key.code, KeyCode::Char(_) | KeyCode::Esc | KeyCode::Enter) { diff --git a/src/input.rs b/src/input.rs index 2eb1d82..449ac5f 100644 --- a/src/input.rs +++ b/src/input.rs @@ -32,6 +32,22 @@ impl Default for KeyMaps { vec![KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)], Action::Quit, ), + ( + vec![KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)], + Action::CursorDown, + ), + ( + vec![KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)], + Action::CursorDown, + ), + ( + vec![KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)], + Action::CursorUp, + ), + ( + vec![KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)], + Action::CursorUp, + ), ( vec![ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE), diff --git a/src/ui/server_list.rs b/src/ui/server_list.rs index 7cb4e79..d8e88ec 100644 --- a/src/ui/server_list.rs +++ b/src/ui/server_list.rs @@ -1,13 +1,85 @@ use crate::app::App; use ratatui::{ Frame, - style::{Color, Style}, - widgets::{Block, Borders, Paragraph}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState}, }; -pub fn render(f: &mut Frame, _app: &App) { - let msg = Paragraph::new("Server list is not yet implemented.") - .style(Style::default().fg(Color::Yellow)) - .block(Block::default().title(" Servers ").borders(Borders::ALL)); - f.render_widget(msg, f.area()); +pub fn render(f: &mut Frame, app: &App) { + let total_items = 1 + app.servers.len(); + let selected_index = app.selected_index.min(total_items.saturating_sub(1)); + + let num_digits = if total_items > 0 { + total_items.to_string().len() + } else { + 1 + }; + + let mut items: Vec = Vec::new(); + + for i in 0..total_items { + let is_selected = i == selected_index; + let rel_num = (i as isize - selected_index as isize).unsigned_abs(); + let width = num_digits.max(2); + + let line_num_str = if is_selected { + format!("{:width$} ", rel_num, width = width) + }; + + let num_style = if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + + let text_span = if i == 0 { + Span::styled( + "Direct Messages", + if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + }, + ) + } else { + let server_name = &app.servers[i - 1].name; + Span::styled( + server_name.as_str(), + if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }, + ) + }; + + items.push(ListItem::new(Line::from(vec![ + Span::styled(line_num_str, num_style), + text_span, + ]))); + } + + let mut state = ListState::default(); + state.select(Some(selected_index)); + + let list = List::new(items) + .block(Block::default().title(" Servers ").borders(Borders::ALL)) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + f.render_stateful_widget(list, f.area(), &mut state); } From a42b754690d7f7e61c47cfe9c70667d134fbc8ac Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:01:46 -0500 Subject: [PATCH 15/55] feat(api): add non-blocking DM list page with recipient resolution --- src/api/client.rs | 1 + src/api/dms.rs | 133 ++++++++++++++++++++++++++++++++++++++++++++++ src/api/mod.rs | 1 + src/app.rs | 74 +++++++++++++++++++++++++- src/input.rs | 8 +++ src/main.rs | 4 ++ src/models.rs | 6 +++ src/ui/dm_list.rs | 94 ++++++++++++++++++++++++++++++++ src/ui/mod.rs | 1 + src/ui/render.rs | 3 +- 10 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 src/api/dms.rs create mode 100644 src/ui/dm_list.rs diff --git a/src/api/client.rs b/src/api/client.rs index 4754340..e285b7a 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -32,6 +32,7 @@ impl Endpoint { } } +#[derive(Debug, Clone)] pub struct ApiClient { client: Client, token: String, diff --git a/src/api/dms.rs b/src/api/dms.rs new file mode 100644 index 0000000..c1421b6 --- /dev/null +++ b/src/api/dms.rs @@ -0,0 +1,133 @@ +use crate::{ + Result, + api::client::{ApiClient, Endpoint}, + models::DirectMessageChannel, +}; + +pub async fn fetch_dms(api_client: &ApiClient) -> Result> { + let dms_json: Vec = api_client.get(Endpoint::Dms).await?; + + let my_user_id = match api_client + .get::(Endpoint::CurrentUser) + .await + { + Ok(user_val) => user_val + .get("_id") + .or_else(|| user_val.get("id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + Err(_) => None, + }; + + let mut dm_channels = Vec::new(); + + for channel in dms_json { + let id = channel + .get("_id") + .or_else(|| channel.get("id")) + .and_then(|v| v.as_str()); + + let mut display_name = channel + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + if display_name.is_none() { + let channel_type = channel + .get("channel_type") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + + if channel_type == "SavedMessages" { + display_name = Some("Saved Messages".to_string()); + } else { + let mut user_ids: Vec = Vec::new(); + + if let Some(recipients_arr) = channel.get("recipients").and_then(|v| v.as_array()) { + for v in recipients_arr { + let id_opt = v.as_str().or_else(|| { + v.get("_id") + .or_else(|| v.get("id")) + .and_then(|i| i.as_str()) + }); + if let Some(id_str) = id_opt { + if !user_ids.contains(&id_str.to_string()) { + user_ids.push(id_str.to_string()); + } + } + } + } + + for key in &["recipient", "user", "user_id"] { + let id_opt = channel.get(*key).and_then(|v| { + v.as_str().or_else(|| { + v.get("_id") + .or_else(|| v.get("id")) + .and_then(|i| i.as_str()) + }) + }); + if let Some(id_str) = id_opt { + if !user_ids.contains(&id_str.to_string()) { + user_ids.push(id_str.to_string()); + } + } + } + + if user_ids.len() == 1 { + let target_id = &user_ids[0]; + if Some(target_id) == my_user_id.as_ref() { + display_name = Some("Saved Messages".to_string()); + } else if let Ok(user_val) = api_client + .get::(Endpoint::User(target_id.clone())) + .await + { + if let Some(username) = user_val.get("username").and_then(|v| v.as_str()) { + display_name = Some(username.to_string()); + } + } + if display_name.is_none() { + display_name = Some(target_id.clone()); + } + } else if user_ids.len() == 2 { + let other_id = if let Some(my_id) = &my_user_id { + user_ids.iter().find(|id| *id != my_id).cloned() + } else { + user_ids.first().cloned() + }; + + if let Some(target_id) = other_id { + if let Ok(user_val) = api_client + .get::(Endpoint::User(target_id.clone())) + .await + { + if let Some(username) = + user_val.get("username").and_then(|v| v.as_str()) + { + display_name = Some(username.to_string()); + } + } + if display_name.is_none() { + display_name = Some(target_id); + } + } + } else if user_ids.len() >= 3 { + display_name = Some(format!("Group DM ({} members)", user_ids.len())); + } + } + } + + let name = display_name.unwrap_or_else(|| { + id.map(|s| format!("DM ({s})")) + .unwrap_or_else(|| "Direct Message".to_string()) + }); + + if let Some(id_str) = id { + dm_channels.push(DirectMessageChannel { + id: id_str.to_string(), + name, + }); + } + } + + Ok(dm_channels) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 1c9346f..8dff5df 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod client; +pub mod dms; pub mod events; pub mod ws; diff --git a/src/app.rs b/src/app.rs index 45859dc..b23431a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,7 +2,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use log::{debug, error, info, warn}; use ratatui::crossterm::event::{KeyCode, KeyEvent}; -use tokio::sync::mpsc::Receiver; +use tokio::sync::mpsc::{self, Receiver, Sender}; use tokio::time; use crate::{ @@ -17,13 +17,18 @@ use crate::{ }, cache::CacheStore, input::InputState, - models::Server, + models::{DirectMessageChannel, Server}, }; +pub enum AppEvent { + DmsLoaded(Vec), +} + pub enum AppState { InputToken, ValidatingToken, LoggedIn, + DmList, Error(anyhow::Error), } @@ -41,6 +46,11 @@ pub struct App { pub cache: CacheStore, pub servers: Vec, pub selected_index: usize, + pub dm_channels: Vec, + pub selected_dm_index: usize, + pub is_loading_dms: bool, + pub app_tx: Sender, + pub app_rx: Receiver, } impl App { @@ -64,6 +74,7 @@ impl App { let (ws_client, ws_rx) = WsClient::connect(ws_base_url).await?; let cache = CacheStore::new()?; + let (app_tx, app_rx) = mpsc::channel::(32); Ok(Self { state, @@ -77,10 +88,24 @@ impl App { cache, servers: Vec::new(), selected_index: 0, + dm_channels: Vec::new(), + selected_dm_index: 0, + is_loading_dms: false, + app_tx, + app_rx, input_state: InputState::default(), }) } + pub fn handle_app_event(&mut self, event: AppEvent) { + match event { + AppEvent::DmsLoaded(dms) => { + self.dm_channels = dms; + self.is_loading_dms = false; + } + } + } + pub async fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { match self.state { AppState::InputToken => match key.code { @@ -123,6 +148,27 @@ impl App { let action = self.input_state.process_key_event(key); match action { Some(Action::Quit) => self.should_quit = true, + Some(Action::Enter) => { + if self.selected_index == 0 { + self.selected_dm_index = 0; + self.state = AppState::DmList; + self.is_loading_dms = true; + + let api_client = self.api_client.clone(); + let app_tx = self.app_tx.clone(); + + tokio::spawn(async move { + match crate::api::dms::fetch_dms(&api_client).await { + Ok(dms) => { + app_tx.send(AppEvent::DmsLoaded(dms)).await.ok(); + } + Err(e) => { + error!("Error fetching DMs in background: {e}"); + } + } + }); + } + } Some(Action::CursorUp) => { if self.selected_index > 0 { self.selected_index -= 1; @@ -140,6 +186,30 @@ impl App { _ => {} } } + AppState::DmList => { + let action = self.input_state.process_key_event(key); + match action { + Some(Action::Quit) => self.should_quit = true, + Some(Action::Escape) => { + self.state = AppState::LoggedIn; + } + Some(Action::CursorUp) => { + if self.selected_dm_index > 0 { + self.selected_dm_index -= 1; + } + } + Some(Action::CursorDown) => { + let total_items = self.dm_channels.len(); + if total_items > 0 && self.selected_dm_index + 1 < total_items { + self.selected_dm_index += 1; + } + } + Some(Action::GoToTopUI) => { + self.selected_dm_index = 0; + } + _ => {} + } + } AppState::Error(_) => { if matches!(key.code, KeyCode::Char(_) | KeyCode::Esc | KeyCode::Enter) { self.state = AppState::InputToken; diff --git a/src/input.rs b/src/input.rs index 449ac5f..daf26d5 100644 --- a/src/input.rs +++ b/src/input.rs @@ -32,6 +32,14 @@ impl Default for KeyMaps { vec![KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)], Action::Quit, ), + ( + vec![KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)], + Action::Enter, + ), + ( + vec![KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)], + Action::Escape, + ), ( vec![KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)], Action::CursorDown, diff --git a/src/main.rs b/src/main.rs index 9c16ee6..99ea594 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,6 +100,10 @@ async fn main() -> anyhow::Result<(), Box> { } } + if let Ok(event) = app.app_rx.try_recv() { + app.handle_app_event(event); + } + if let Ok(event) = app.ws_rx.try_recv() { debug!("Received WebSocket event: {event:?}"); api::ws::EventHandler::new(&mut app.servers).handle_event(&event); diff --git a/src/models.rs b/src/models.rs index d3339b7..ed128d6 100644 --- a/src/models.rs +++ b/src/models.rs @@ -6,3 +6,9 @@ pub struct Server { pub name: String, pub description: Option, } + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct DirectMessageChannel { + pub id: String, + pub name: String, +} diff --git a/src/ui/dm_list.rs b/src/ui/dm_list.rs new file mode 100644 index 0000000..96bc49a --- /dev/null +++ b/src/ui/dm_list.rs @@ -0,0 +1,94 @@ +use crate::app::App; +use ratatui::{ + Frame, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, +}; + +pub fn render(f: &mut Frame, app: &App) { + let total_items = app.dm_channels.len(); + + if app.is_loading_dms && total_items == 0 { + let msg = Paragraph::new("Loading Direct Messages...") + .style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .block( + Block::default() + .title(" Direct Messages ") + .borders(Borders::ALL), + ); + f.render_widget(msg, f.area()); + return; + } + + if total_items == 0 { + let msg = Paragraph::new("No Direct Messages found. (Press Esc to return)") + .style(Style::default().fg(Color::DarkGray)) + .block( + Block::default() + .title(" Direct Messages ") + .borders(Borders::ALL), + ); + f.render_widget(msg, f.area()); + return; + } + + let selected_index = app.selected_dm_index.min(total_items.saturating_sub(1)); + let num_digits = total_items.to_string().len(); + + let mut items: Vec = Vec::new(); + + for (i, channel) in app.dm_channels.iter().enumerate() { + let is_selected = i == selected_index; + let rel_num = (i as isize - selected_index as isize).unsigned_abs(); + let width = num_digits.max(2); + + let line_num_str = if is_selected { + format!("{:width$} ", rel_num, width = width) + }; + + let num_style = if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + + let text_style = if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + + items.push(ListItem::new(Line::from(vec![ + Span::styled(line_num_str, num_style), + Span::styled(channel.name.as_str(), text_style), + ]))); + } + + let mut state = ListState::default(); + state.select(Some(selected_index)); + + let list = List::new(items) + .block( + Block::default() + .title(" Direct Messages ") + .borders(Borders::ALL), + ) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + + f.render_stateful_widget(list, f.area(), &mut state); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4904799..3d8c563 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ +mod dm_list; mod error; mod input_token; mod render; diff --git a/src/ui/render.rs b/src/ui/render.rs index b56bfb3..e250016 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -1,13 +1,14 @@ use crate::app::{App, AppState}; use ratatui::Frame; -use super::{error, input_token, server_list, validating_token}; +use super::{dm_list, error, input_token, server_list, validating_token}; pub fn render(f: &mut Frame, app: &App) { match &app.state { AppState::InputToken => input_token::render(f, app), AppState::ValidatingToken => validating_token::render(f), AppState::LoggedIn => server_list::render(f, app), + AppState::DmList => dm_list::render(f, app), AppState::Error(message) => error::render(f, &message.to_string()), } } From 59286ae7fc099b470dc15dc6699cfbcfb6207ae0 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:08:41 -0500 Subject: [PATCH 16/55] feat(command): create command module for Vim command parsing and execution --- src/action.rs | 2 ++ src/app.rs | 37 ++++++++++++++++++++++++- src/command.rs | 63 +++++++++++++++++++++++++++++++++++++++++++ src/input.rs | 16 ++++++----- src/main.rs | 1 + src/ui/dm_list.rs | 23 +++++++++++----- src/ui/render.rs | 44 +++++++++++++++++++++++++++--- src/ui/server_list.rs | 17 +++++++++--- 8 files changed, 182 insertions(+), 21 deletions(-) create mode 100644 src/command.rs diff --git a/src/action.rs b/src/action.rs index 51c92cc..eba6af7 100644 --- a/src/action.rs +++ b/src/action.rs @@ -6,11 +6,13 @@ pub enum Action { #[allow(unused)] AppendCharacter(char), RemoveCharacter, + EnterCommandMode, Enter, CursorLeft, CursorRight, CursorUp, CursorDown, Escape, + #[allow(unused)] Quit, } diff --git a/src/app.rs b/src/app.rs index b23431a..1dff751 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,8 @@ use crate::{ ws::WsClient, }, cache::CacheStore, - input::InputState, + command::Command, + input::{InputMode, InputState}, models::{DirectMessageChannel, Server}, }; @@ -35,6 +36,7 @@ pub enum AppState { pub struct App { pub state: AppState, pub input_text: String, + pub command_text: String, pub auth: Auth, pub should_quit: bool, pub input_state: InputState, @@ -79,6 +81,7 @@ impl App { Ok(Self { state, input_text: String::new(), + command_text: String::new(), auth, should_quit: false, api_base_url: api_base_url.unwrap_or(API_BASE_URL.to_string()), @@ -107,6 +110,30 @@ impl App { } pub async fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { + if matches!(self.input_state.input_mode, InputMode::Command) { + let action = self.input_state.process_key_event(key); + match action { + Some(Action::AppendCharacter(c)) => { + self.command_text.push(c); + } + Some(Action::RemoveCharacter) => { + self.command_text.pop(); + } + Some(Action::Escape) => { + self.command_text.clear(); + self.input_state.change_input_mode(InputMode::UI); + } + Some(Action::Enter) => { + if let Some(cmd) = Command::parse(&self.command_text) { + cmd.execute(self); + } + self.command_text.clear(); + self.input_state.change_input_mode(InputMode::UI); + } + _ => {} + } + return Ok(()); + } match self.state { AppState::InputToken => match key.code { KeyCode::Enter => { @@ -148,6 +175,10 @@ impl App { let action = self.input_state.process_key_event(key); match action { Some(Action::Quit) => self.should_quit = true, + Some(Action::EnterCommandMode) => { + self.command_text.clear(); + self.input_state.change_input_mode(InputMode::Command); + } Some(Action::Enter) => { if self.selected_index == 0 { self.selected_dm_index = 0; @@ -190,6 +221,10 @@ impl App { let action = self.input_state.process_key_event(key); match action { Some(Action::Quit) => self.should_quit = true, + Some(Action::EnterCommandMode) => { + self.command_text.clear(); + self.input_state.change_input_mode(InputMode::Command); + } Some(Action::Escape) => { self.state = AppState::LoggedIn; } diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 0000000..8f5144c --- /dev/null +++ b/src/command.rs @@ -0,0 +1,63 @@ +use crate::app::App; + +/// Represents commands entered in Vim command mode (e.g. `:q`, `:quit`) +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Command { + Quit, + Unknown(String), +} + +impl Command { + /// Parses a raw command string (without leading colon) into a `Command`. + pub fn parse(input: &str) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + return None; + } + + let cmd = match trimmed { + "q" | "quit" | "q!" => Command::Quit, + other => Command::Unknown(other.to_string()), + }; + + Some(cmd) + } + + pub fn execute(&self, app: &mut App) { + match self { + Command::Quit => { + app.should_quit = true; + } + Command::Unknown(cmd_name) => { + log::warn!("Unknown command: :{cmd_name}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_quit_commands() { + assert_eq!(Command::parse("q"), Some(Command::Quit)); + assert_eq!(Command::parse("quit"), Some(Command::Quit)); + assert_eq!(Command::parse("q!"), Some(Command::Quit)); + assert_eq!(Command::parse(" q "), Some(Command::Quit)); + } + + #[test] + fn test_parse_unknown_command() { + assert_eq!( + Command::parse("foo"), + Some(Command::Unknown("foo".to_string())) + ); + } + + #[test] + fn test_parse_empty() { + assert_eq!(Command::parse(""), None); + assert_eq!(Command::parse(" "), None); + } +} diff --git a/src/input.rs b/src/input.rs index daf26d5..6437bed 100644 --- a/src/input.rs +++ b/src/input.rs @@ -29,8 +29,12 @@ impl Default for KeyMaps { Self { ui: HashMap::from([ ( - vec![KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)], - Action::Quit, + vec![KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)], + Action::EnterCommandMode, + ), + ( + vec![KeyEvent::new(KeyCode::Char(':'), KeyModifiers::SHIFT)], + Action::EnterCommandMode, ), ( vec![KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)], @@ -118,7 +122,7 @@ impl Default for InputState { impl InputState { #[allow(unused)] - fn change_input_mode(&mut self, new_mode: InputMode) { + pub fn change_input_mode(&mut self, new_mode: InputMode) { self.pending_keys.clear(); self.input_mode = new_mode; } @@ -189,9 +193,9 @@ mod test { "Should have no action" ); assert_eq!( - state.process_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), - Some(Action::Quit), - "Should have done the quit action" + state.process_key_event(KeyEvent::new(KeyCode::Char(':'), KeyModifiers::NONE)), + Some(Action::EnterCommandMode), + "Should have done the enter command mode action" ); } } diff --git a/src/main.rs b/src/main.rs index 99ea594..b4a94c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod action; mod api; mod app; mod cache; +mod command; mod error; mod input; mod models; diff --git a/src/ui/dm_list.rs b/src/ui/dm_list.rs index 96bc49a..9bd146e 100644 --- a/src/ui/dm_list.rs +++ b/src/ui/dm_list.rs @@ -6,9 +6,15 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, }; -pub fn render(f: &mut Frame, app: &App) { +pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { let total_items = app.dm_channels.len(); + let border_color = if matches!(app.input_state.input_mode, crate::input::InputMode::Command) { + Color::Green + } else { + Color::Reset + }; + if app.is_loading_dms && total_items == 0 { let msg = Paragraph::new("Loading Direct Messages...") .style( @@ -19,9 +25,10 @@ pub fn render(f: &mut Frame, app: &App) { .block( Block::default() .title(" Direct Messages ") - .borders(Borders::ALL), + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)), ); - f.render_widget(msg, f.area()); + f.render_widget(msg, area); return; } @@ -31,9 +38,10 @@ pub fn render(f: &mut Frame, app: &App) { .block( Block::default() .title(" Direct Messages ") - .borders(Borders::ALL), + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)), ); - f.render_widget(msg, f.area()); + f.render_widget(msg, area); return; } @@ -82,7 +90,8 @@ pub fn render(f: &mut Frame, app: &App) { .block( Block::default() .title(" Direct Messages ") - .borders(Borders::ALL), + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)), ) .highlight_style( Style::default() @@ -90,5 +99,5 @@ pub fn render(f: &mut Frame, app: &App) { .add_modifier(Modifier::BOLD), ); - f.render_stateful_widget(list, f.area(), &mut state); + f.render_stateful_widget(list, area, &mut state); } diff --git a/src/ui/render.rs b/src/ui/render.rs index e250016..440280f 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -1,14 +1,50 @@ -use crate::app::{App, AppState}; -use ratatui::Frame; +use crate::{ + app::{App, AppState}, + input::InputMode, +}; +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Paragraph}, +}; use super::{dm_list, error, input_token, server_list, validating_token}; pub fn render(f: &mut Frame, app: &App) { + let is_command_mode = matches!(app.input_state.input_mode, InputMode::Command); + + let (main_area, command_area) = if is_command_mode { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(3)]) + .split(f.area()); + (chunks[0], Some(chunks[1])) + } else { + (f.area(), None) + }; + match &app.state { AppState::InputToken => input_token::render(f, app), AppState::ValidatingToken => validating_token::render(f), - AppState::LoggedIn => server_list::render(f, app), - AppState::DmList => dm_list::render(f, app), + AppState::LoggedIn => server_list::render(f, app, main_area), + AppState::DmList => dm_list::render(f, app, main_area), AppState::Error(message) => error::render(f, &message.to_string()), } + + if let Some(cmd_area) = command_area { + let cmd_widget = Paragraph::new(format!(":{}", app.command_text)) + .style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .block( + Block::default() + .title(" Command ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Green)), + ); + f.render_widget(cmd_widget, cmd_area); + } } diff --git a/src/ui/server_list.rs b/src/ui/server_list.rs index d8e88ec..264bbaa 100644 --- a/src/ui/server_list.rs +++ b/src/ui/server_list.rs @@ -6,7 +6,7 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, ListState}, }; -pub fn render(f: &mut Frame, app: &App) { +pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { let total_items = 1 + app.servers.len(); let selected_index = app.selected_index.min(total_items.saturating_sub(1)); @@ -73,13 +73,24 @@ pub fn render(f: &mut Frame, app: &App) { let mut state = ListState::default(); state.select(Some(selected_index)); + let border_color = if matches!(app.input_state.input_mode, crate::input::InputMode::Command) { + Color::Green + } else { + Color::Reset + }; + let list = List::new(items) - .block(Block::default().title(" Servers ").borders(Borders::ALL)) + .block( + Block::default() + .title(" Servers ") + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)), + ) .highlight_style( Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ); - f.render_stateful_widget(list, f.area(), &mut state); + f.render_stateful_widget(list, area, &mut state); } From 5e5cf29c102d69a6f5c387ac79fae5fb658973d4 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:37:34 -0500 Subject: [PATCH 17/55] feat(ui): added first basic version of a DM page --- src/action.rs | 1 - src/app.rs | 20 ++++++++++++++++++++ src/ui/dm.rs | 36 ++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 1 + src/ui/render.rs | 3 ++- 5 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 src/ui/dm.rs diff --git a/src/action.rs b/src/action.rs index eba6af7..129f234 100644 --- a/src/action.rs +++ b/src/action.rs @@ -1,4 +1,3 @@ -/// Action based on user input #[derive(Debug, Clone, Copy, PartialEq)] pub enum Action { #[allow(unused)] diff --git a/src/app.rs b/src/app.rs index 1dff751..403f85b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -30,6 +30,7 @@ pub enum AppState { ValidatingToken, LoggedIn, DmList, + Dm, Error(anyhow::Error), } @@ -242,6 +243,25 @@ impl App { Some(Action::GoToTopUI) => { self.selected_dm_index = 0; } + Some(Action::Enter) => { + if !self.dm_channels.is_empty() { + self.state = AppState::Dm; + } + } + _ => {} + } + } + AppState::Dm => { + let action = self.input_state.process_key_event(key); + match action { + Some(Action::Quit) => self.should_quit = true, + Some(Action::EnterCommandMode) => { + self.command_text.clear(); + self.input_state.change_input_mode(InputMode::Command); + } + Some(Action::Escape) => { + self.state = AppState::DmList; + } _ => {} } } diff --git a/src/ui/dm.rs b/src/ui/dm.rs new file mode 100644 index 0000000..709c258 --- /dev/null +++ b/src/ui/dm.rs @@ -0,0 +1,36 @@ +use crate::app::App; +use ratatui::{ + Frame, + style::{Color, Style}, + text::Line, + widgets::{Block, Borders, Paragraph}, +}; + +pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let border_color = if matches!(app.input_state.input_mode, crate::input::InputMode::Command) { + Color::Green + } else { + Color::Reset + }; + + let title = if let Some(channel) = app.dm_channels.get(app.selected_dm_index) { + format!(" Direct Message: {} ", channel.name) + } else { + " Direct Message ".to_string() + }; + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)); + + let content = vec![ + Line::from("").style(Style::default()), + Line::from(" No messages yet... (Press Esc to return to list)") + .style(Style::default().fg(Color::DarkGray)), + ]; + + let paragraph = Paragraph::new(content).block(block); + + f.render_widget(paragraph, area); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 3d8c563..ef1acb8 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,4 @@ +mod dm; mod dm_list; mod error; mod input_token; diff --git a/src/ui/render.rs b/src/ui/render.rs index 440280f..be58386 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -9,7 +9,7 @@ use ratatui::{ widgets::{Block, Borders, Paragraph}, }; -use super::{dm_list, error, input_token, server_list, validating_token}; +use super::{dm, dm_list, error, input_token, server_list, validating_token}; pub fn render(f: &mut Frame, app: &App) { let is_command_mode = matches!(app.input_state.input_mode, InputMode::Command); @@ -29,6 +29,7 @@ pub fn render(f: &mut Frame, app: &App) { AppState::ValidatingToken => validating_token::render(f), AppState::LoggedIn => server_list::render(f, app, main_area), AppState::DmList => dm_list::render(f, app, main_area), + AppState::Dm => dm::render(f, app, main_area), AppState::Error(message) => error::render(f, &message.to_string()), } From 1bc007a17d9e34a39fe85ef115d2cefc02b91676 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:01:01 -0500 Subject: [PATCH 18/55] feat(ui): added basic message history viewing for dm's --- src/api/channel.rs | 51 +++++++++++++++++++++++++++++++++++++ src/api/client.rs | 2 ++ src/api/mod.rs | 1 + src/app.rs | 46 +++++++++++++++++++++++++++++++++ src/ui/dm.rs | 63 ++++++++++++++++++++++++++++++++++++++-------- 5 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 src/api/channel.rs diff --git a/src/api/channel.rs b/src/api/channel.rs new file mode 100644 index 0000000..140d173 --- /dev/null +++ b/src/api/channel.rs @@ -0,0 +1,51 @@ +use crate::{ + Result, + api::client::{ApiClient, Endpoint}, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct MessageHistoryQuery { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nearby: Option, +} + +pub async fn fetch_message_history( + api_client: &ApiClient, + channel_id: &str, + query: Option<&MessageHistoryQuery>, +) -> Result> { + let mut path = format!("/channels/{}/messages", channel_id); + if let Some(q) = query { + let mut params = Vec::new(); + if let Some(limit) = q.limit { + params.push(format!("limit={}", limit)); + } + if let Some(before) = &q.before { + params.push(format!("before={}", before)); + } + if let Some(after) = &q.after { + params.push(format!("after={}", after)); + } + if let Some(sort) = &q.sort { + params.push(format!("sort={}", sort)); + } + if let Some(nearby) = &q.nearby { + params.push(format!("nearby={}", nearby)); + } + if !params.is_empty() { + path.push_str("?"); + path.push_str(¶ms.join("&")); + } + } + + api_client.get(Endpoint::Custom(path)).await +} diff --git a/src/api/client.rs b/src/api/client.rs index e285b7a..60d8624 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -15,6 +15,7 @@ pub enum Endpoint { Channel(String), MessageHistory(String), SendMessage(String), + Custom(String), } impl Endpoint { @@ -28,6 +29,7 @@ impl Endpoint { Self::Channel(id) => format!("/channels/{}", id), Self::MessageHistory(id) => format!("/channels/{}/messages", id), Self::SendMessage(id) => format!("/channels/{}/messages", id), + Self::Custom(path) => path.clone(), } } } diff --git a/src/api/mod.rs b/src/api/mod.rs index 8dff5df..9e596c0 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod channel; pub mod client; pub mod dms; pub mod events; diff --git a/src/app.rs b/src/app.rs index 403f85b..c8f90ff 100644 --- a/src/app.rs +++ b/src/app.rs @@ -23,6 +23,7 @@ use crate::{ pub enum AppEvent { DmsLoaded(Vec), + DmMessagesLoaded(Vec), } pub enum AppState { @@ -52,6 +53,8 @@ pub struct App { pub dm_channels: Vec, pub selected_dm_index: usize, pub is_loading_dms: bool, + pub current_dm_messages: Vec, + pub is_loading_messages: bool, pub app_tx: Sender, pub app_rx: Receiver, } @@ -95,6 +98,8 @@ impl App { dm_channels: Vec::new(), selected_dm_index: 0, is_loading_dms: false, + current_dm_messages: Vec::new(), + is_loading_messages: false, app_tx, app_rx, input_state: InputState::default(), @@ -107,6 +112,10 @@ impl App { self.dm_channels = dms; self.is_loading_dms = false; } + AppEvent::DmMessagesLoaded(messages) => { + self.current_dm_messages = messages; + self.is_loading_messages = false; + } } } @@ -245,7 +254,44 @@ impl App { } Some(Action::Enter) => { if !self.dm_channels.is_empty() { + let channel_id = self.dm_channels[self.selected_dm_index].id.clone(); self.state = AppState::Dm; + self.is_loading_messages = true; + self.current_dm_messages.clear(); + + let api_client = self.api_client.clone(); + let app_tx = self.app_tx.clone(); + + tokio::spawn(async move { + let query = crate::api::channel::MessageHistoryQuery { + limit: Some(50), + before: None, + after: None, + sort: None, + nearby: None, + }; + match crate::api::channel::fetch_message_history( + &api_client, + &channel_id, + Some(&query), + ) + .await + { + Ok(messages) => { + app_tx + .send(AppEvent::DmMessagesLoaded(messages)) + .await + .ok(); + } + Err(e) => { + error!("Error fetching messages: {e}"); + app_tx + .send(AppEvent::DmMessagesLoaded(Vec::new())) + .await + .ok(); + } + } + }); } } _ => {} diff --git a/src/ui/dm.rs b/src/ui/dm.rs index 709c258..958a0ab 100644 --- a/src/ui/dm.rs +++ b/src/ui/dm.rs @@ -1,9 +1,9 @@ use crate::app::App; use ratatui::{ Frame, - style::{Color, Style}, - text::Line, - widgets::{Block, Borders, Paragraph}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, Paragraph}, }; pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { @@ -24,13 +24,56 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { .borders(Borders::ALL) .border_style(Style::default().fg(border_color)); - let content = vec![ - Line::from("").style(Style::default()), - Line::from(" No messages yet... (Press Esc to return to list)") - .style(Style::default().fg(Color::DarkGray)), - ]; + if app.is_loading_messages { + let msg = Paragraph::new("Loading messages...") + .style(Style::default().fg(Color::Yellow)) + .block(block); + f.render_widget(msg, area); + return; + } - let paragraph = Paragraph::new(content).block(block); + if app.current_dm_messages.is_empty() { + let msg = Paragraph::new("No messages found. (Press Esc to return)") + .style(Style::default().fg(Color::DarkGray)) + .block(block); + f.render_widget(msg, area); + return; + } - f.render_widget(paragraph, area); + let mut items = Vec::new(); + + // Revolt API returns messages in descending order (newest first). + // We reverse to render oldest at top and newest at bottom. + for msg in app.current_dm_messages.iter().rev() { + let author_id = msg + .get("author") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + + let content_str = if let Some(content) = msg.get("content").and_then(|v| v.as_str()) { + content.to_string() + } else if let Some(sys) = msg.get("system") { + format!( + "[System message: {}]", + sys.get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + ) + } else { + "[Unsupported message]".to_string() + }; + + let author_span = Span::styled( + format!("{}: ", author_id), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + let content_span = Span::raw(content_str); + + items.push(ListItem::new(Line::from(vec![author_span, content_span]))); + } + + let list = List::new(items).block(block); + f.render_widget(list, area); } From 02bf87c5622308b273ac0216bd8c96d348ad026d Mon Sep 17 00:00:00 2001 From: YetAnotherMechanicusEnjoyer Date: Sun, 23 Aug 2026 10:13:16 +0200 Subject: [PATCH 19/55] feat(api): add username caching --- src/api/dms.rs | 61 +++++++++++++++++++++++++++++++++++++------------- src/app.rs | 10 +++++---- src/main.rs | 24 +++++++++++++++++++- src/models.rs | 6 +++++ src/ui/dm.rs | 10 ++++++++- 5 files changed, 89 insertions(+), 22 deletions(-) diff --git a/src/api/dms.rs b/src/api/dms.rs index c1421b6..7b96e3b 100644 --- a/src/api/dms.rs +++ b/src/api/dms.rs @@ -1,10 +1,18 @@ +use std::sync::Arc; + +use tokio::sync::Mutex; + use crate::{ Result, api::client::{ApiClient, Endpoint}, - models::DirectMessageChannel, + cache::{CacheStore, Id}, + models::{DirectMessageChannel, User}, }; -pub async fn fetch_dms(api_client: &ApiClient) -> Result> { +pub async fn fetch_dms( + api_client: &ApiClient, + cache: Arc>, +) -> Result> { let dms_json: Vec = api_client.get(Endpoint::Dms).await?; let my_user_id = match api_client @@ -50,10 +58,10 @@ pub async fn fetch_dms(api_client: &ApiClient) -> Result Result Result(Endpoint::User(target_id.clone())) .await + && let Some(username) = user_val.get("username").and_then(|v| v.as_str()) { - if let Some(username) = user_val.get("username").and_then(|v| v.as_str()) { - display_name = Some(username.to_string()); + display_name = Some(username.to_string()); + + if let Ok(uid) = Id::::new(target_id) { + let mut cache_locked = cache.lock().await; + cache_locked + .set( + uid, + &User { + id: target_id.clone(), + username: username.to_string(), + }, + ) + .ok(); } } if display_name.is_none() { @@ -99,11 +119,20 @@ pub async fn fetch_dms(api_client: &ApiClient) -> Result(Endpoint::User(target_id.clone())) .await - { - if let Some(username) = + && let Some(username) = user_val.get("username").and_then(|v| v.as_str()) - { - display_name = Some(username.to_string()); + { + display_name = Some(username.to_string()); + + if let Ok(uid) = Id::::new(&target_id) { + let mut cache_locked = cache.lock().await; + let _ = cache_locked.set( + uid, + &User { + id: target_id.clone(), + username: username.to_string(), + }, + ); } } if display_name.is_none() { diff --git a/src/app.rs b/src/app.rs index c8f90ff..ccc4d34 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,9 @@ +use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use log::{debug, error, info, warn}; use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use tokio::sync::Mutex; use tokio::sync::mpsc::{self, Receiver, Sender}; use tokio::time; @@ -46,8 +48,7 @@ pub struct App { pub api_client: ApiClient, pub ws_client: WsClient, pub ws_rx: Receiver, - #[allow(unused)] - pub cache: CacheStore, + pub cache: Arc>, pub servers: Vec, pub selected_index: usize, pub dm_channels: Vec, @@ -79,7 +80,7 @@ impl App { let (ws_client, ws_rx) = WsClient::connect(ws_base_url).await?; - let cache = CacheStore::new()?; + let cache = Arc::new(Mutex::new(CacheStore::new()?)); let (app_tx, app_rx) = mpsc::channel::(32); Ok(Self { @@ -195,11 +196,12 @@ impl App { self.state = AppState::DmList; self.is_loading_dms = true; + let cache = self.cache.clone(); let api_client = self.api_client.clone(); let app_tx = self.app_tx.clone(); tokio::spawn(async move { - match crate::api::dms::fetch_dms(&api_client).await { + match crate::api::dms::fetch_dms(&api_client, cache).await { Ok(dms) => { app_tx.send(AppEvent::DmsLoaded(dms)).await.ok(); } diff --git a/src/main.rs b/src/main.rs index b4a94c9..a4a11a3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,7 @@ use log::debug; use notify_rust::{CloseReason, NotificationResponse}; use ratatui::crossterm::event::{self, Event}; -use crate::notification::NotifyHandler; +use crate::{api::client::Endpoint, cache::Id, notification::NotifyHandler}; pub const LOG_FILE: &str = "logs"; @@ -60,6 +60,28 @@ async fn main() -> anyhow::Result<(), Box> { app.authenticate_ws(&app.api_client.clone_token()).await?; + if let Ok(me_val) = app + .api_client + .get::(Endpoint::CurrentUser) + .await + && let (Some(my_id), Some(my_username)) = ( + me_val.get("_id").and_then(|v| v.as_str()), + me_val.get("username").and_then(|v| v.as_str()), + ) + && let Ok(uid) = Id::::new(my_id) + { + let mut cache_locked = app.cache.lock().await; + cache_locked + .set( + uid, + &crate::models::User { + id: my_id.to_string(), + username: my_username.to_string(), + }, + ) + .ok(); + } + /* This is an example, for now we have no use for notifications */ { let n = NotifyHandler::new().await?; diff --git a/src/models.rs b/src/models.rs index ed128d6..dc49a47 100644 --- a/src/models.rs +++ b/src/models.rs @@ -12,3 +12,9 @@ pub struct DirectMessageChannel { pub id: String, pub name: String, } + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct User { + pub id: String, + pub username: String, +} diff --git a/src/ui/dm.rs b/src/ui/dm.rs index 958a0ab..defe966 100644 --- a/src/ui/dm.rs +++ b/src/ui/dm.rs @@ -50,6 +50,14 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { .and_then(|v| v.as_str()) .unwrap_or("Unknown"); + let mut display_name = author_id.to_string(); + if let Ok(uid) = crate::cache::Id::::new(author_id) + && let Ok(cache_lock) = app.cache.try_lock() + && let Some(cached_user) = cache_lock.get(uid) + { + display_name = cached_user.username; + } + let content_str = if let Some(content) = msg.get("content").and_then(|v| v.as_str()) { content.to_string() } else if let Some(sys) = msg.get("system") { @@ -64,7 +72,7 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { }; let author_span = Span::styled( - format!("{}: ", author_id), + format!("{}: ", display_name), Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), From d936fdee2ac2b2e8ddd6da96c60ba5d6b8912495 Mon Sep 17 00:00:00 2001 From: YetAnotherMechanicusEnjoyer Date: Sun, 23 Aug 2026 10:22:32 +0200 Subject: [PATCH 20/55] fix: clippy warning (i can't remove this damn pre-push script) --- src/api/channel.rs | 2 +- src/api/ws.rs | 1 + src/app.rs | 71 +++++++++++++++++++++------------------------- 3 files changed, 35 insertions(+), 39 deletions(-) diff --git a/src/api/channel.rs b/src/api/channel.rs index 140d173..4ca680c 100644 --- a/src/api/channel.rs +++ b/src/api/channel.rs @@ -42,7 +42,7 @@ pub async fn fetch_message_history( params.push(format!("nearby={}", nearby)); } if !params.is_empty() { - path.push_str("?"); + path.push('?'); path.push_str(¶ms.join("&")); } } diff --git a/src/api/ws.rs b/src/api/ws.rs index 2b23540..9ccddea 100644 --- a/src/api/ws.rs +++ b/src/api/ws.rs @@ -96,6 +96,7 @@ impl<'a> EventHandler<'a> { } pub fn handle_event(&mut self, event: &ServerEvent) { + #[allow(clippy::single_match)] match event { ServerEvent::Ready { servers, .. } => { self.handle_ready(servers.as_deref()); diff --git a/src/app.rs b/src/app.rs index ccc4d34..da45640 100644 --- a/src/app.rs +++ b/src/app.rs @@ -254,47 +254,42 @@ impl App { Some(Action::GoToTopUI) => { self.selected_dm_index = 0; } - Some(Action::Enter) => { - if !self.dm_channels.is_empty() { - let channel_id = self.dm_channels[self.selected_dm_index].id.clone(); - self.state = AppState::Dm; - self.is_loading_messages = true; - self.current_dm_messages.clear(); + Some(Action::Enter) if !self.dm_channels.is_empty() => { + let channel_id = self.dm_channels[self.selected_dm_index].id.clone(); + self.state = AppState::Dm; + self.is_loading_messages = true; + self.current_dm_messages.clear(); - let api_client = self.api_client.clone(); - let app_tx = self.app_tx.clone(); + let api_client = self.api_client.clone(); + let app_tx = self.app_tx.clone(); - tokio::spawn(async move { - let query = crate::api::channel::MessageHistoryQuery { - limit: Some(50), - before: None, - after: None, - sort: None, - nearby: None, - }; - match crate::api::channel::fetch_message_history( - &api_client, - &channel_id, - Some(&query), - ) - .await - { - Ok(messages) => { - app_tx - .send(AppEvent::DmMessagesLoaded(messages)) - .await - .ok(); - } - Err(e) => { - error!("Error fetching messages: {e}"); - app_tx - .send(AppEvent::DmMessagesLoaded(Vec::new())) - .await - .ok(); - } + tokio::spawn(async move { + let query = crate::api::channel::MessageHistoryQuery { + limit: Some(50), + before: None, + after: None, + sort: None, + nearby: None, + }; + match crate::api::channel::fetch_message_history( + &api_client, + &channel_id, + Some(&query), + ) + .await + { + Ok(messages) => { + app_tx.send(AppEvent::DmMessagesLoaded(messages)).await.ok(); } - }); - } + Err(e) => { + error!("Error fetching messages: {e}"); + app_tx + .send(AppEvent::DmMessagesLoaded(Vec::new())) + .await + .ok(); + } + } + }); } _ => {} } From ee13dabc49c4048abc5cad73edf70d1cecd45690 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:40:47 -0500 Subject: [PATCH 21/55] refactor(api): rename dms module to dm --- src/api/{dms.rs => dm.rs} | 0 src/api/mod.rs | 2 +- src/app.rs | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/api/{dms.rs => dm.rs} (100%) diff --git a/src/api/dms.rs b/src/api/dm.rs similarity index 100% rename from src/api/dms.rs rename to src/api/dm.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index 9e596c0..39abba2 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,7 +1,7 @@ pub mod auth; pub mod channel; pub mod client; -pub mod dms; +pub mod dm; pub mod events; pub mod ws; diff --git a/src/app.rs b/src/app.rs index da45640..8a114cf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -201,7 +201,7 @@ impl App { let app_tx = self.app_tx.clone(); tokio::spawn(async move { - match crate::api::dms::fetch_dms(&api_client, cache).await { + match crate::api::dm::fetch_dms(&api_client, cache).await { Ok(dms) => { app_tx.send(AppEvent::DmsLoaded(dms)).await.ok(); } From 457cc6e5ed7292ee999e788bcb5068b6127ca339 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:45:43 -0500 Subject: [PATCH 22/55] feat(ux): use :q for screen navigation and :qa to quit app --- src/app.rs | 14 ++++++++------ src/command.rs | 8 ++++++++ src/ui/dm.rs | 2 +- src/ui/dm_list.rs | 2 +- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/app.rs b/src/app.rs index 8a114cf..8e081b8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -120,6 +120,14 @@ impl App { } } + pub fn go_back_or_quit(&mut self) { + match self.state { + AppState::DmList => self.state = AppState::LoggedIn, + AppState::Dm => self.state = AppState::DmList, + _ => self.should_quit = true, + } + } + pub async fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { if matches!(self.input_state.input_mode, InputMode::Command) { let action = self.input_state.process_key_event(key); @@ -237,9 +245,6 @@ impl App { self.command_text.clear(); self.input_state.change_input_mode(InputMode::Command); } - Some(Action::Escape) => { - self.state = AppState::LoggedIn; - } Some(Action::CursorUp) => { if self.selected_dm_index > 0 { self.selected_dm_index -= 1; @@ -302,9 +307,6 @@ impl App { self.command_text.clear(); self.input_state.change_input_mode(InputMode::Command); } - Some(Action::Escape) => { - self.state = AppState::DmList; - } _ => {} } } diff --git a/src/command.rs b/src/command.rs index 8f5144c..2a35540 100644 --- a/src/command.rs +++ b/src/command.rs @@ -4,6 +4,7 @@ use crate::app::App; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { Quit, + QuitAll, Unknown(String), } @@ -17,6 +18,7 @@ impl Command { let cmd = match trimmed { "q" | "quit" | "q!" => Command::Quit, + "qa" | "qa!" | "qall" | "qall!" => Command::QuitAll, other => Command::Unknown(other.to_string()), }; @@ -26,6 +28,9 @@ impl Command { pub fn execute(&self, app: &mut App) { match self { Command::Quit => { + app.go_back_or_quit(); + } + Command::QuitAll => { app.should_quit = true; } Command::Unknown(cmd_name) => { @@ -45,6 +50,9 @@ mod tests { assert_eq!(Command::parse("quit"), Some(Command::Quit)); assert_eq!(Command::parse("q!"), Some(Command::Quit)); assert_eq!(Command::parse(" q "), Some(Command::Quit)); + assert_eq!(Command::parse("qa"), Some(Command::QuitAll)); + assert_eq!(Command::parse("qa!"), Some(Command::QuitAll)); + assert_eq!(Command::parse("qall"), Some(Command::QuitAll)); } #[test] diff --git a/src/ui/dm.rs b/src/ui/dm.rs index defe966..58c6e2a 100644 --- a/src/ui/dm.rs +++ b/src/ui/dm.rs @@ -33,7 +33,7 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { } if app.current_dm_messages.is_empty() { - let msg = Paragraph::new("No messages found. (Press Esc to return)") + let msg = Paragraph::new("No messages found. (Type :q to return)") .style(Style::default().fg(Color::DarkGray)) .block(block); f.render_widget(msg, area); diff --git a/src/ui/dm_list.rs b/src/ui/dm_list.rs index 9bd146e..561bc72 100644 --- a/src/ui/dm_list.rs +++ b/src/ui/dm_list.rs @@ -33,7 +33,7 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { } if total_items == 0 { - let msg = Paragraph::new("No Direct Messages found. (Press Esc to return)") + let msg = Paragraph::new("No Direct Messages found. (Type :q to return)") .style(Style::default().fg(Color::DarkGray)) .block( Block::default() From ea16a8dfd4c20d773a69644cc975d5fede4ce050 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:08:12 -0500 Subject: [PATCH 23/55] perf(ui): pre-resolve dm author names into memory to remove render loop disk cache hits --- src/app.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++++---- src/models.rs | 8 +++++++ src/ui/dm.rs | 30 ++------------------------ 3 files changed, 66 insertions(+), 32 deletions(-) diff --git a/src/app.rs b/src/app.rs index 8e081b8..7a94216 100644 --- a/src/app.rs +++ b/src/app.rs @@ -25,7 +25,7 @@ use crate::{ pub enum AppEvent { DmsLoaded(Vec), - DmMessagesLoaded(Vec), + DmMessagesLoaded(Vec), } pub enum AppState { @@ -54,7 +54,7 @@ pub struct App { pub dm_channels: Vec, pub selected_dm_index: usize, pub is_loading_dms: bool, - pub current_dm_messages: Vec, + pub current_dm_messages: Vec, pub is_loading_messages: bool, pub app_tx: Sender, pub app_rx: Receiver, @@ -267,6 +267,7 @@ impl App { let api_client = self.api_client.clone(); let app_tx = self.app_tx.clone(); + let cache = self.cache.clone(); tokio::spawn(async move { let query = crate::api::channel::MessageHistoryQuery { @@ -283,8 +284,59 @@ impl App { ) .await { - Ok(messages) => { - app_tx.send(AppEvent::DmMessagesLoaded(messages)).await.ok(); + Ok(messages_json) => { + let mut parsed_messages = + Vec::with_capacity(messages_json.len()); + let cache_locked = cache.lock().await; + + for msg in messages_json { + let id = msg + .get("_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let author_id = msg + .get("author") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + + let mut author_name = author_id.clone(); + if let Ok(uid) = + crate::cache::Id::::new(&author_id) + && let Some(cached_user) = cache_locked.get(uid) + { + author_name = cached_user.username; + } + + let content = if let Some(content_val) = + msg.get("content").and_then(|v| v.as_str()) + { + content_val.to_string() + } else if let Some(sys) = msg.get("system") { + format!( + "[System message: {}]", + sys.get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + ) + } else { + "[Unsupported message]".to_string() + }; + + parsed_messages.push(crate::models::Message { + id, + author_id, + author_name, + content, + }); + } + + app_tx + .send(AppEvent::DmMessagesLoaded(parsed_messages)) + .await + .ok(); } Err(e) => { error!("Error fetching messages: {e}"); diff --git a/src/models.rs b/src/models.rs index dc49a47..901334c 100644 --- a/src/models.rs +++ b/src/models.rs @@ -18,3 +18,11 @@ pub struct User { pub id: String, pub username: String, } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + pub id: String, + pub author_id: String, + pub author_name: String, + pub content: String, +} diff --git a/src/ui/dm.rs b/src/ui/dm.rs index 58c6e2a..18083b6 100644 --- a/src/ui/dm.rs +++ b/src/ui/dm.rs @@ -45,39 +45,13 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { // Revolt API returns messages in descending order (newest first). // We reverse to render oldest at top and newest at bottom. for msg in app.current_dm_messages.iter().rev() { - let author_id = msg - .get("author") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown"); - - let mut display_name = author_id.to_string(); - if let Ok(uid) = crate::cache::Id::::new(author_id) - && let Ok(cache_lock) = app.cache.try_lock() - && let Some(cached_user) = cache_lock.get(uid) - { - display_name = cached_user.username; - } - - let content_str = if let Some(content) = msg.get("content").and_then(|v| v.as_str()) { - content.to_string() - } else if let Some(sys) = msg.get("system") { - format!( - "[System message: {}]", - sys.get("type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - ) - } else { - "[Unsupported message]".to_string() - }; - let author_span = Span::styled( - format!("{}: ", display_name), + format!("{}: ", msg.author_name), Style::default() .fg(Color::Cyan) .add_modifier(Modifier::BOLD), ); - let content_span = Span::raw(content_str); + let content_span = Span::raw(&msg.content); items.push(ListItem::new(Line::from(vec![author_span, content_span]))); } From fc24a3bab69ef7550eb7f807206e475370fe7585 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:13:27 -0500 Subject: [PATCH 24/55] perf(api): check local cache before fetching dm recipient user profiles --- src/api/dm.rs | 89 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 56 insertions(+), 33 deletions(-) diff --git a/src/api/dm.rs b/src/api/dm.rs index 7b96e3b..2cbfa9d 100644 --- a/src/api/dm.rs +++ b/src/api/dm.rs @@ -85,24 +85,36 @@ pub async fn fetch_dms( let target_id = &user_ids[0]; if Some(target_id) == my_user_id.as_ref() { display_name = Some("Saved Messages".to_string()); - } else if let Ok(user_val) = api_client - .get::(Endpoint::User(target_id.clone())) - .await - && let Some(username) = user_val.get("username").and_then(|v| v.as_str()) - { - display_name = Some(username.to_string()); - + } else { + // Check cache first! if let Ok(uid) = Id::::new(target_id) { - let mut cache_locked = cache.lock().await; - cache_locked - .set( - uid, - &User { - id: target_id.clone(), - username: username.to_string(), - }, - ) - .ok(); + let cache_locked = cache.lock().await; + if let Some(cached_user) = cache_locked.get(uid.clone()) { + display_name = Some(cached_user.username); + } + } + + // Only fetch from API if it wasn't in the cache + if display_name.is_none() + && let Ok(user_val) = api_client + .get::(Endpoint::User(target_id.clone())) + .await + && let Some(username) = + user_val.get("username").and_then(|v| v.as_str()) + { + display_name = Some(username.to_string()); + if let Ok(uid) = Id::::new(target_id) { + let mut cache_locked = cache.lock().await; + cache_locked + .set( + uid, + &User { + id: target_id.clone(), + username: username.to_string(), + }, + ) + .ok(); + } } } if display_name.is_none() { @@ -116,23 +128,34 @@ pub async fn fetch_dms( }; if let Some(target_id) = other_id { - if let Ok(user_val) = api_client - .get::(Endpoint::User(target_id.clone())) - .await - && let Some(username) = - user_val.get("username").and_then(|v| v.as_str()) - { - display_name = Some(username.to_string()); + // Check cache first! + if let Ok(uid) = Id::::new(&target_id) { + let cache_locked = cache.lock().await; + if let Some(cached_user) = cache_locked.get(uid.clone()) { + display_name = Some(cached_user.username); + } + } - if let Ok(uid) = Id::::new(&target_id) { - let mut cache_locked = cache.lock().await; - let _ = cache_locked.set( - uid, - &User { - id: target_id.clone(), - username: username.to_string(), - }, - ); + // Only fetch from API if it wasn't in the cache + if display_name.is_none() { + if let Ok(user_val) = api_client + .get::(Endpoint::User(target_id.clone())) + .await + && let Some(username) = + user_val.get("username").and_then(|v| v.as_str()) + { + display_name = Some(username.to_string()); + + if let Ok(uid) = Id::::new(&target_id) { + let mut cache_locked = cache.lock().await; + let _ = cache_locked.set( + uid, + &User { + id: target_id.clone(), + username: username.to_string(), + }, + ); + } } } if display_name.is_none() { From 723fe4a09e989e920a007018e75ddedd64738355 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:20:47 -0500 Subject: [PATCH 25/55] refactor(app): extract server, dm channel, and message state into AppStore --- src/app.rs | 29 ++++++++++++++++------------- src/main.rs | 2 +- src/ui/dm.rs | 6 +++--- src/ui/dm_list.rs | 4 ++-- src/ui/server_list.rs | 4 ++-- 5 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/app.rs b/src/app.rs index 7a94216..f4dfebf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -37,6 +37,13 @@ pub enum AppState { Error(anyhow::Error), } +#[derive(Default)] +pub struct AppStore { + pub servers: Vec, + pub dm_channels: Vec, + pub current_dm_messages: Vec, +} + pub struct App { pub state: AppState, pub input_text: String, @@ -49,12 +56,10 @@ pub struct App { pub ws_client: WsClient, pub ws_rx: Receiver, pub cache: Arc>, - pub servers: Vec, + pub store: AppStore, pub selected_index: usize, - pub dm_channels: Vec, pub selected_dm_index: usize, pub is_loading_dms: bool, - pub current_dm_messages: Vec, pub is_loading_messages: bool, pub app_tx: Sender, pub app_rx: Receiver, @@ -94,12 +99,10 @@ impl App { ws_client, ws_rx, cache, - servers: Vec::new(), + store: AppStore::default(), selected_index: 0, - dm_channels: Vec::new(), selected_dm_index: 0, is_loading_dms: false, - current_dm_messages: Vec::new(), is_loading_messages: false, app_tx, app_rx, @@ -110,11 +113,11 @@ impl App { pub fn handle_app_event(&mut self, event: AppEvent) { match event { AppEvent::DmsLoaded(dms) => { - self.dm_channels = dms; + self.store.dm_channels = dms; self.is_loading_dms = false; } AppEvent::DmMessagesLoaded(messages) => { - self.current_dm_messages = messages; + self.store.current_dm_messages = messages; self.is_loading_messages = false; } } @@ -226,7 +229,7 @@ impl App { } } Some(Action::CursorDown) => { - let total_items = 1 + self.servers.len(); + let total_items = 1 + self.store.servers.len(); if total_items > 0 && self.selected_index + 1 < total_items { self.selected_index += 1; } @@ -251,7 +254,7 @@ impl App { } } Some(Action::CursorDown) => { - let total_items = self.dm_channels.len(); + let total_items = self.store.dm_channels.len(); if total_items > 0 && self.selected_dm_index + 1 < total_items { self.selected_dm_index += 1; } @@ -259,11 +262,11 @@ impl App { Some(Action::GoToTopUI) => { self.selected_dm_index = 0; } - Some(Action::Enter) if !self.dm_channels.is_empty() => { - let channel_id = self.dm_channels[self.selected_dm_index].id.clone(); + Some(Action::Enter) if !self.store.dm_channels.is_empty() => { + let channel_id = self.store.dm_channels[self.selected_dm_index].id.clone(); self.state = AppState::Dm; self.is_loading_messages = true; - self.current_dm_messages.clear(); + self.store.current_dm_messages.clear(); let api_client = self.api_client.clone(); let app_tx = self.app_tx.clone(); diff --git a/src/main.rs b/src/main.rs index a4a11a3..c7855e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -129,7 +129,7 @@ async fn main() -> anyhow::Result<(), Box> { if let Ok(event) = app.ws_rx.try_recv() { debug!("Received WebSocket event: {event:?}"); - api::ws::EventHandler::new(&mut app.servers).handle_event(&event); + api::ws::EventHandler::new(&mut app.store.servers).handle_event(&event); } } diff --git a/src/ui/dm.rs b/src/ui/dm.rs index 18083b6..389c0b8 100644 --- a/src/ui/dm.rs +++ b/src/ui/dm.rs @@ -13,7 +13,7 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { Color::Reset }; - let title = if let Some(channel) = app.dm_channels.get(app.selected_dm_index) { + let title = if let Some(channel) = app.store.dm_channels.get(app.selected_dm_index) { format!(" Direct Message: {} ", channel.name) } else { " Direct Message ".to_string() @@ -32,7 +32,7 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { return; } - if app.current_dm_messages.is_empty() { + if app.store.current_dm_messages.is_empty() { let msg = Paragraph::new("No messages found. (Type :q to return)") .style(Style::default().fg(Color::DarkGray)) .block(block); @@ -44,7 +44,7 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { // Revolt API returns messages in descending order (newest first). // We reverse to render oldest at top and newest at bottom. - for msg in app.current_dm_messages.iter().rev() { + for msg in app.store.current_dm_messages.iter().rev() { let author_span = Span::styled( format!("{}: ", msg.author_name), Style::default() diff --git a/src/ui/dm_list.rs b/src/ui/dm_list.rs index 561bc72..aa5dcce 100644 --- a/src/ui/dm_list.rs +++ b/src/ui/dm_list.rs @@ -7,7 +7,7 @@ use ratatui::{ }; pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { - let total_items = app.dm_channels.len(); + let total_items = app.store.dm_channels.len(); let border_color = if matches!(app.input_state.input_mode, crate::input::InputMode::Command) { Color::Green @@ -50,7 +50,7 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { let mut items: Vec = Vec::new(); - for (i, channel) in app.dm_channels.iter().enumerate() { + for (i, channel) in app.store.dm_channels.iter().enumerate() { let is_selected = i == selected_index; let rel_num = (i as isize - selected_index as isize).unsigned_abs(); let width = num_digits.max(2); diff --git a/src/ui/server_list.rs b/src/ui/server_list.rs index 264bbaa..6ec68f6 100644 --- a/src/ui/server_list.rs +++ b/src/ui/server_list.rs @@ -7,7 +7,7 @@ use ratatui::{ }; pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { - let total_items = 1 + app.servers.len(); + let total_items = 1 + app.store.servers.len(); let selected_index = app.selected_index.min(total_items.saturating_sub(1)); let num_digits = if total_items > 0 { @@ -51,7 +51,7 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { }, ) } else { - let server_name = &app.servers[i - 1].name; + let server_name = &app.store.servers[i - 1].name; Span::styled( server_name.as_str(), if is_selected { From e25823598a28766ade0ea4a20991ddf50b1f2ca2 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:59:03 -0500 Subject: [PATCH 26/55] refactor(cache): switch to in-memory user cache with disk sync on exit --- src/api/dm.rs | 74 +++++++++++++++------------------------------------ src/app.rs | 47 +++++++++++++++++++------------- src/cache.rs | 24 ++++++++++++++--- src/main.rs | 12 +++++++++ 4 files changed, 83 insertions(+), 74 deletions(-) diff --git a/src/api/dm.rs b/src/api/dm.rs index 2cbfa9d..5e8c42f 100644 --- a/src/api/dm.rs +++ b/src/api/dm.rs @@ -1,18 +1,13 @@ -use std::sync::Arc; - -use tokio::sync::Mutex; - use crate::{ Result, api::client::{ApiClient, Endpoint}, - cache::{CacheStore, Id}, models::{DirectMessageChannel, User}, }; pub async fn fetch_dms( api_client: &ApiClient, - cache: Arc>, -) -> Result> { + known_users: &std::collections::HashMap, +) -> Result<(Vec, Vec)> { let dms_json: Vec = api_client.get(Endpoint::Dms).await?; let my_user_id = match api_client @@ -28,6 +23,7 @@ pub async fn fetch_dms( }; let mut dm_channels = Vec::new(); + let mut new_users = Vec::new(); for channel in dms_json { let id = channel @@ -86,15 +82,10 @@ pub async fn fetch_dms( if Some(target_id) == my_user_id.as_ref() { display_name = Some("Saved Messages".to_string()); } else { - // Check cache first! - if let Ok(uid) = Id::::new(target_id) { - let cache_locked = cache.lock().await; - if let Some(cached_user) = cache_locked.get(uid.clone()) { - display_name = Some(cached_user.username); - } + if let Some(user) = known_users.get(target_id) { + display_name = Some(user.username.clone()); } - // Only fetch from API if it wasn't in the cache if display_name.is_none() && let Ok(user_val) = api_client .get::(Endpoint::User(target_id.clone())) @@ -103,18 +94,10 @@ pub async fn fetch_dms( user_val.get("username").and_then(|v| v.as_str()) { display_name = Some(username.to_string()); - if let Ok(uid) = Id::::new(target_id) { - let mut cache_locked = cache.lock().await; - cache_locked - .set( - uid, - &User { - id: target_id.clone(), - username: username.to_string(), - }, - ) - .ok(); - } + new_users.push(User { + id: target_id.clone(), + username: username.to_string(), + }); } } if display_name.is_none() { @@ -128,35 +111,22 @@ pub async fn fetch_dms( }; if let Some(target_id) = other_id { - // Check cache first! - if let Ok(uid) = Id::::new(&target_id) { - let cache_locked = cache.lock().await; - if let Some(cached_user) = cache_locked.get(uid.clone()) { - display_name = Some(cached_user.username); - } + if let Some(user) = known_users.get(&target_id) { + display_name = Some(user.username.clone()); } - // Only fetch from API if it wasn't in the cache - if display_name.is_none() { - if let Ok(user_val) = api_client + if display_name.is_none() + && let Ok(user_val) = api_client .get::(Endpoint::User(target_id.clone())) .await - && let Some(username) = - user_val.get("username").and_then(|v| v.as_str()) - { - display_name = Some(username.to_string()); - - if let Ok(uid) = Id::::new(&target_id) { - let mut cache_locked = cache.lock().await; - let _ = cache_locked.set( - uid, - &User { - id: target_id.clone(), - username: username.to_string(), - }, - ); - } - } + && let Some(username) = + user_val.get("username").and_then(|v| v.as_str()) + { + display_name = Some(username.to_string()); + new_users.push(User { + id: target_id.clone(), + username: username.to_string(), + }); } if display_name.is_none() { display_name = Some(target_id); @@ -181,5 +151,5 @@ pub async fn fetch_dms( } } - Ok(dm_channels) + Ok((dm_channels, new_users)) } diff --git a/src/app.rs b/src/app.rs index f4dfebf..9e8c2c8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -24,8 +24,8 @@ use crate::{ }; pub enum AppEvent { - DmsLoaded(Vec), - DmMessagesLoaded(Vec), + DmsLoaded(Vec, Vec), + DmMessagesLoaded(Vec, Vec), } pub enum AppState { @@ -42,6 +42,7 @@ pub struct AppStore { pub servers: Vec, pub dm_channels: Vec, pub current_dm_messages: Vec, + pub users: std::collections::HashMap, } pub struct App { @@ -98,8 +99,11 @@ impl App { api_client, ws_client, ws_rx, - cache, - store: AppStore::default(), + cache: cache.clone(), + store: AppStore { + users: cache.lock().await.get_all_users(), + ..Default::default() + }, selected_index: 0, selected_dm_index: 0, is_loading_dms: false, @@ -112,11 +116,17 @@ impl App { pub fn handle_app_event(&mut self, event: AppEvent) { match event { - AppEvent::DmsLoaded(dms) => { + AppEvent::DmsLoaded(dms, new_users) => { + for user in new_users { + self.store.users.insert(user.id.clone(), user); + } self.store.dm_channels = dms; self.is_loading_dms = false; } - AppEvent::DmMessagesLoaded(messages) => { + AppEvent::DmMessagesLoaded(messages, new_users) => { + for user in new_users { + self.store.users.insert(user.id.clone(), user); + } self.store.current_dm_messages = messages; self.is_loading_messages = false; } @@ -207,14 +217,14 @@ impl App { self.state = AppState::DmList; self.is_loading_dms = true; - let cache = self.cache.clone(); + let users = self.store.users.clone(); let api_client = self.api_client.clone(); let app_tx = self.app_tx.clone(); tokio::spawn(async move { - match crate::api::dm::fetch_dms(&api_client, cache).await { - Ok(dms) => { - app_tx.send(AppEvent::DmsLoaded(dms)).await.ok(); + match crate::api::dm::fetch_dms(&api_client, &users).await { + Ok((dms, new_users)) => { + app_tx.send(AppEvent::DmsLoaded(dms, new_users)).await.ok(); } Err(e) => { error!("Error fetching DMs in background: {e}"); @@ -270,7 +280,7 @@ impl App { let api_client = self.api_client.clone(); let app_tx = self.app_tx.clone(); - let cache = self.cache.clone(); + let users = self.store.users.clone(); tokio::spawn(async move { let query = crate::api::channel::MessageHistoryQuery { @@ -290,7 +300,6 @@ impl App { Ok(messages_json) => { let mut parsed_messages = Vec::with_capacity(messages_json.len()); - let cache_locked = cache.lock().await; for msg in messages_json { let id = msg @@ -306,11 +315,8 @@ impl App { .to_string(); let mut author_name = author_id.clone(); - if let Ok(uid) = - crate::cache::Id::::new(&author_id) - && let Some(cached_user) = cache_locked.get(uid) - { - author_name = cached_user.username; + if let Some(user) = users.get(&author_id) { + author_name = user.username.clone(); } let content = if let Some(content_val) = @@ -337,14 +343,17 @@ impl App { } app_tx - .send(AppEvent::DmMessagesLoaded(parsed_messages)) + .send(AppEvent::DmMessagesLoaded( + parsed_messages, + Vec::new(), + )) .await .ok(); } Err(e) => { error!("Error fetching messages: {e}"); app_tx - .send(AppEvent::DmMessagesLoaded(Vec::new())) + .send(AppEvent::DmMessagesLoaded(Vec::new(), Vec::new())) .await .ok(); } diff --git a/src/cache.rs b/src/cache.rs index 7d36b91..facb7a0 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -78,14 +78,14 @@ impl CacheStore { let db = if path.exists() { PickleDb::load( &path, - pickledb::PickleDbDumpPolicy::AutoDump, + pickledb::PickleDbDumpPolicy::DumpUponRequest, pickledb::SerializationMethod::Bin, ) .map_err(CacheError::DbError)? } else { PickleDb::new( &path, - pickledb::PickleDbDumpPolicy::AutoDump, + pickledb::PickleDbDumpPolicy::DumpUponRequest, pickledb::SerializationMethod::Bin, ) }; @@ -98,12 +98,30 @@ impl CacheStore { let path = dir.join(DB_FILE); let db = PickleDb::new( &path, - pickledb::PickleDbDumpPolicy::AutoDump, + pickledb::PickleDbDumpPolicy::DumpUponRequest, pickledb::SerializationMethod::Bin, ); Ok(Self { db, path }) } + pub fn dump(&mut self) -> Result<()> { + log::info!("Dumping cache to disk..."); + self.db.dump().map_err(CacheError::DbError)?; + Ok(()) + } + + pub fn get_all_users(&self) -> std::collections::HashMap { + let mut users = std::collections::HashMap::new(); + for key in self.db.get_all() { + if key.starts_with("user:") { + if let Some(user) = self.db.get::(&key) { + users.insert(user.id.clone(), user); + } + } + } + users + } + pub fn set(&mut self, id: Id, value: &V) -> Result<()> { let key = Self::build_key::(id)?; diff --git a/src/main.rs b/src/main.rs index c7855e8..83b74de 100644 --- a/src/main.rs +++ b/src/main.rs @@ -133,6 +133,18 @@ async fn main() -> anyhow::Result<(), Box> { } } + { + let mut cache_locked = app.cache.lock().await; + for user in app.store.users.values() { + if let Ok(uid) = crate::cache::Id::::new(&user.id) { + let _ = cache_locked.set(uid, user); + } + } + if let Err(e) = cache_locked.dump() { + log::error!("Failed to dump cache to disk: {}", e); + } + } + ratatui::restore(); Ok(()) } From 7a46dd9243168e4432d5fadb66111ed46126e472 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:18:32 -0500 Subject: [PATCH 27/55] feat(ui): add temporary placeholder message box to DM view --- src/action.rs | 1 + src/app.rs | 20 ++++++++++++++++++++ src/input.rs | 12 ++++++++++++ src/main.rs | 14 ++++++++++++++ src/ui/dm.rs | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/action.rs b/src/action.rs index 129f234..7026d34 100644 --- a/src/action.rs +++ b/src/action.rs @@ -6,6 +6,7 @@ pub enum Action { AppendCharacter(char), RemoveCharacter, EnterCommandMode, + EnterInsertMode, Enter, CursorLeft, CursorRight, diff --git a/src/app.rs b/src/app.rs index 9e8c2c8..089da8b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -371,6 +371,26 @@ impl App { self.command_text.clear(); self.input_state.change_input_mode(InputMode::Command); } + Some(Action::EnterInsertMode) => { + self.input_text.clear(); + self.input_state.change_input_mode(InputMode::Insert); + } + Some(Action::AppendCharacter(c)) => { + self.input_text.push(c); + } + Some(Action::RemoveCharacter) => { + self.input_text.pop(); + } + Some(Action::Escape) => { + self.input_state.change_input_mode(InputMode::UI); + } + Some(Action::Enter) + if matches!(self.input_state.input_mode, InputMode::Insert) => + { + // TODO: Actually send the message over WS/HTTP + self.input_text.clear(); + self.input_state.change_input_mode(InputMode::UI); + } _ => {} } } diff --git a/src/input.rs b/src/input.rs index 6437bed..cdb765c 100644 --- a/src/input.rs +++ b/src/input.rs @@ -40,6 +40,10 @@ impl Default for KeyMaps { vec![KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)], Action::Enter, ), + ( + vec![KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)], + Action::EnterInsertMode, + ), ( vec![KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)], Action::Escape, @@ -79,6 +83,14 @@ impl Default for KeyMaps { vec![KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)], Action::Enter, ), + ( + vec![KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)], + Action::AppendCharacter('\n'), + ), + ( + vec![KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)], + Action::AppendCharacter('\n'), + ), ( vec![KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)], Action::CursorLeft, diff --git a/src/main.rs b/src/main.rs index 83b74de..3df951c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,14 @@ async fn main() -> anyhow::Result<(), Box> { let mut terminal = ratatui::init(); + ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::event::PushKeyboardEnhancementFlags( + ratatui::crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + ) + ) + .ok(); + let api_base_url = std::env::var("API_BASE_URL").ok(); let ws_base_url = std::env::var("WS_BASE_URL").ok(); @@ -145,6 +153,12 @@ async fn main() -> anyhow::Result<(), Box> { } } + ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::event::PopKeyboardEnhancementFlags + ) + .ok(); + ratatui::restore(); Ok(()) } diff --git a/src/ui/dm.rs b/src/ui/dm.rs index 389c0b8..336730e 100644 --- a/src/ui/dm.rs +++ b/src/ui/dm.rs @@ -56,6 +56,52 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { items.push(ListItem::new(Line::from(vec![author_span, content_span]))); } + // Calculate the height of the message input box based on lines of text + let input_lines = (app.input_text.matches('\n').count() as u16) + 1; + let input_height = input_lines + 2; // +2 for borders + + let chunks = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + ratatui::layout::Constraint::Min(0), + ratatui::layout::Constraint::Length(input_height), + ]) + .split(area); + + let messages_area = chunks[0]; + let input_area = chunks[1]; + let list = List::new(items).block(block); - f.render_widget(list, area); + f.render_widget(list, messages_area); + + let input_border_color = + if matches!(app.input_state.input_mode, crate::input::InputMode::Insert) { + Color::Cyan + } else { + Color::Reset + }; + + let input_block = Block::default() + .title(" Message [Visual Only - Not hooked to API] (Type 'i' to insert, ESC for normal) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(input_border_color)); + + let input_paragraph = Paragraph::new(app.input_text.as_str()).block(input_block); + + f.render_widget(input_paragraph, input_area); + + if matches!(app.input_state.input_mode, crate::input::InputMode::Insert) { + // Find the X and Y offsets for the cursor + let lines: Vec<&str> = app.input_text.split('\n').collect(); + let current_line = lines.last().unwrap_or(&""); + + let cursor_x = input_area.x + 1 + current_line.chars().count() as u16; + let cursor_y = input_area.y + 1 + (lines.len() as u16).saturating_sub(1); + + // Clamp inside the input_area (avoid panics if text overflows horizontally) + let clamped_x = cursor_x.min(input_area.x + input_area.width.saturating_sub(2)); + let clamped_y = cursor_y.min(input_area.y + input_area.height.saturating_sub(2)); + + f.set_cursor_position(ratatui::layout::Position::new(clamped_x, clamped_y)); + } } From 03fcb2c7f634c0106b69cb42ec1b931fbff696ae Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:32:13 -0500 Subject: [PATCH 28/55] feat(ui): add text wrapping and auto-scroll to DM view --- src/cache.rs | 8 ++--- src/ui/dm.rs | 89 ++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 70 insertions(+), 27 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index facb7a0..3a77a92 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -113,10 +113,10 @@ impl CacheStore { pub fn get_all_users(&self) -> std::collections::HashMap { let mut users = std::collections::HashMap::new(); for key in self.db.get_all() { - if key.starts_with("user:") { - if let Some(user) = self.db.get::(&key) { - users.insert(user.id.clone(), user); - } + if key.starts_with("user:") + && let Some(user) = self.db.get::(&key) + { + users.insert(user.id.clone(), user); } } users diff --git a/src/ui/dm.rs b/src/ui/dm.rs index 336730e..c1be1b4 100644 --- a/src/ui/dm.rs +++ b/src/ui/dm.rs @@ -3,7 +3,7 @@ use ratatui::{ Frame, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, Paragraph}, + widgets::{Block, Borders, Paragraph}, }; pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { @@ -40,25 +40,45 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { return; } - let mut items = Vec::new(); + let mut message_lines = Vec::new(); // Revolt API returns messages in descending order (newest first). // We reverse to render oldest at top and newest at bottom. for msg in app.store.current_dm_messages.iter().rev() { - let author_span = Span::styled( - format!("{}: ", msg.author_name), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ); - let content_span = Span::raw(&msg.content); - - items.push(ListItem::new(Line::from(vec![author_span, content_span]))); + let mut first = true; + for line_str in msg.content.split('\n') { + if first { + let author_span = Span::styled( + format!("{}: ", msg.author_name), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + let content_span = Span::raw(line_str); + message_lines.push(Line::from(vec![author_span, content_span])); + first = false; + } else { + message_lines.push(Line::from(vec![Span::raw(line_str)])); + } + } } - // Calculate the height of the message input box based on lines of text - let input_lines = (app.input_text.matches('\n').count() as u16) + 1; - let input_height = input_lines + 2; // +2 for borders + let text_width = area.width.saturating_sub(2).max(1) as usize; + let mut input_lines = 0; + let split_lines: Vec<&str> = app.input_text.split('\n').collect(); + + for (i, line) in split_lines.iter().enumerate() { + let chars = line.chars().count(); + if i == split_lines.len() - 1 { + input_lines += (chars / text_width) + 1; + } else if chars == 0 { + input_lines += 1; + } else { + input_lines += chars.div_ceil(text_width); + } + } + + let input_height = (input_lines as u16) + 2; // +2 for borders let chunks = ratatui::layout::Layout::default() .direction(ratatui::layout::Direction::Vertical) @@ -71,8 +91,31 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { let messages_area = chunks[0]; let input_area = chunks[1]; - let list = List::new(items).block(block); - f.render_widget(list, messages_area); + let msg_text_width = messages_area.width.saturating_sub(2).max(1) as usize; + let mut total_msg_lines = 0; + + for line in &message_lines { + let chars = line + .spans + .iter() + .map(|s| s.content.chars().count()) + .sum::(); + if chars == 0 { + total_msg_lines += 1; + } else { + total_msg_lines += chars.div_ceil(msg_text_width); + } + } + + let scroll = + total_msg_lines.saturating_sub(messages_area.height.saturating_sub(2) as usize) as u16; + + let msg_paragraph = Paragraph::new(message_lines) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((scroll, 0)); + + f.render_widget(msg_paragraph, messages_area); let input_border_color = if matches!(app.input_state.input_mode, crate::input::InputMode::Insert) { @@ -86,19 +129,19 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { .borders(Borders::ALL) .border_style(Style::default().fg(input_border_color)); - let input_paragraph = Paragraph::new(app.input_text.as_str()).block(input_block); + let input_paragraph = Paragraph::new(app.input_text.as_str()) + .block(input_block) + .wrap(ratatui::widgets::Wrap { trim: false }); f.render_widget(input_paragraph, input_area); if matches!(app.input_state.input_mode, crate::input::InputMode::Insert) { - // Find the X and Y offsets for the cursor - let lines: Vec<&str> = app.input_text.split('\n').collect(); - let current_line = lines.last().unwrap_or(&""); + let current_line = split_lines.last().unwrap_or(&""); + let current_line_chars = current_line.chars().count(); - let cursor_x = input_area.x + 1 + current_line.chars().count() as u16; - let cursor_y = input_area.y + 1 + (lines.len() as u16).saturating_sub(1); + let cursor_x = input_area.x + 1 + (current_line_chars % text_width) as u16; + let cursor_y = input_area.y + 1 + (input_lines as u16).saturating_sub(1); - // Clamp inside the input_area (avoid panics if text overflows horizontally) let clamped_x = cursor_x.min(input_area.x + input_area.width.saturating_sub(2)); let clamped_y = cursor_y.min(input_area.y + input_area.height.saturating_sub(2)); From 7d95dacc7b3d1e244e66590f8620ae067d5a59c2 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:41:16 -0500 Subject: [PATCH 29/55] feat(ui): update hardware cursor shape based on input mode --- src/app.rs | 27 +++++++++++++++++++-------- src/main.rs | 6 ++++-- src/ui/dm.rs | 5 ++++- src/ui/input_token.rs | 5 +++++ src/ui/render.rs | 6 ++++++ 5 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/app.rs b/src/app.rs index 089da8b..0709132 100644 --- a/src/app.rs +++ b/src/app.rs @@ -141,6 +141,17 @@ impl App { } } + pub fn set_input_mode(&mut self, new_mode: InputMode) { + self.input_state.change_input_mode(new_mode); + let style = match new_mode { + InputMode::Insert | InputMode::Command => { + ratatui::crossterm::cursor::SetCursorStyle::SteadyBar + } + _ => ratatui::crossterm::cursor::SetCursorStyle::SteadyBlock, + }; + let _ = ratatui::crossterm::execute!(std::io::stdout(), style); + } + pub async fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { if matches!(self.input_state.input_mode, InputMode::Command) { let action = self.input_state.process_key_event(key); @@ -153,14 +164,14 @@ impl App { } Some(Action::Escape) => { self.command_text.clear(); - self.input_state.change_input_mode(InputMode::UI); + self.set_input_mode(InputMode::UI); } Some(Action::Enter) => { if let Some(cmd) = Command::parse(&self.command_text) { cmd.execute(self); } self.command_text.clear(); - self.input_state.change_input_mode(InputMode::UI); + self.set_input_mode(InputMode::UI); } _ => {} } @@ -209,7 +220,7 @@ impl App { Some(Action::Quit) => self.should_quit = true, Some(Action::EnterCommandMode) => { self.command_text.clear(); - self.input_state.change_input_mode(InputMode::Command); + self.set_input_mode(InputMode::Command); } Some(Action::Enter) => { if self.selected_index == 0 { @@ -256,7 +267,7 @@ impl App { Some(Action::Quit) => self.should_quit = true, Some(Action::EnterCommandMode) => { self.command_text.clear(); - self.input_state.change_input_mode(InputMode::Command); + self.set_input_mode(InputMode::Command); } Some(Action::CursorUp) => { if self.selected_dm_index > 0 { @@ -369,11 +380,11 @@ impl App { Some(Action::Quit) => self.should_quit = true, Some(Action::EnterCommandMode) => { self.command_text.clear(); - self.input_state.change_input_mode(InputMode::Command); + self.set_input_mode(InputMode::Command); } Some(Action::EnterInsertMode) => { self.input_text.clear(); - self.input_state.change_input_mode(InputMode::Insert); + self.set_input_mode(InputMode::Insert); } Some(Action::AppendCharacter(c)) => { self.input_text.push(c); @@ -382,14 +393,14 @@ impl App { self.input_text.pop(); } Some(Action::Escape) => { - self.input_state.change_input_mode(InputMode::UI); + self.set_input_mode(InputMode::UI); } Some(Action::Enter) if matches!(self.input_state.input_mode, InputMode::Insert) => { // TODO: Actually send the message over WS/HTTP self.input_text.clear(); - self.input_state.change_input_mode(InputMode::UI); + self.set_input_mode(InputMode::UI); } _ => {} } diff --git a/src/main.rs b/src/main.rs index 3df951c..4750bb0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,7 +57,8 @@ async fn main() -> anyhow::Result<(), Box> { std::io::stdout(), ratatui::crossterm::event::PushKeyboardEnhancementFlags( ratatui::crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES - ) + ), + ratatui::crossterm::cursor::SetCursorStyle::SteadyBlock ) .ok(); @@ -155,7 +156,8 @@ async fn main() -> anyhow::Result<(), Box> { ratatui::crossterm::execute!( std::io::stdout(), - ratatui::crossterm::event::PopKeyboardEnhancementFlags + ratatui::crossterm::event::PopKeyboardEnhancementFlags, + ratatui::crossterm::cursor::SetCursorStyle::DefaultUserShape ) .ok(); diff --git a/src/ui/dm.rs b/src/ui/dm.rs index c1be1b4..80a24de 100644 --- a/src/ui/dm.rs +++ b/src/ui/dm.rs @@ -135,7 +135,10 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { f.render_widget(input_paragraph, input_area); - if matches!(app.input_state.input_mode, crate::input::InputMode::Insert) { + if matches!( + app.input_state.input_mode, + crate::input::InputMode::Insert | crate::input::InputMode::UI + ) { let current_line = split_lines.last().unwrap_or(&""); let current_line_chars = current_line.chars().count(); diff --git a/src/ui/input_token.rs b/src/ui/input_token.rs index 5bf321d..b858074 100644 --- a/src/ui/input_token.rs +++ b/src/ui/input_token.rs @@ -27,4 +27,9 @@ pub fn render(f: &mut Frame, app: &App) { let input_block = Paragraph::new(app.input_text.as_str()) .block(Block::default().title(" User Token ").borders(Borders::ALL)); f.render_widget(input_block, chunks[1]); + + f.set_cursor_position(ratatui::layout::Position::new( + chunks[1].x + 1 + app.input_text.chars().count() as u16, + chunks[1].y + 1, + )); } diff --git a/src/ui/render.rs b/src/ui/render.rs index be58386..00af207 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -47,5 +47,11 @@ pub fn render(f: &mut Frame, app: &App) { .border_style(Style::default().fg(Color::Green)), ); f.render_widget(cmd_widget, cmd_area); + + // Ensure cursor is placed within the command input box + f.set_cursor_position(ratatui::layout::Position::new( + cmd_area.x + 2 + app.command_text.chars().count() as u16, + cmd_area.y + 1, + )); } } From f7bd10dfcec922fb4677b7d0a24b078ab2bf896b Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:42:49 -0500 Subject: [PATCH 30/55] style(ui): switch to blinking cursor variants for normal and insert modes --- src/app.rs | 4 ++-- src/main.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0709132..7bae6aa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -145,9 +145,9 @@ impl App { self.input_state.change_input_mode(new_mode); let style = match new_mode { InputMode::Insert | InputMode::Command => { - ratatui::crossterm::cursor::SetCursorStyle::SteadyBar + ratatui::crossterm::cursor::SetCursorStyle::BlinkingBar } - _ => ratatui::crossterm::cursor::SetCursorStyle::SteadyBlock, + _ => ratatui::crossterm::cursor::SetCursorStyle::BlinkingBlock, }; let _ = ratatui::crossterm::execute!(std::io::stdout(), style); } diff --git a/src/main.rs b/src/main.rs index 4750bb0..50b28f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,7 +58,7 @@ async fn main() -> anyhow::Result<(), Box> { ratatui::crossterm::event::PushKeyboardEnhancementFlags( ratatui::crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES ), - ratatui::crossterm::cursor::SetCursorStyle::SteadyBlock + ratatui::crossterm::cursor::SetCursorStyle::BlinkingBlock ) .ok(); From f64288f4607180beff075afb16b83cf3ff3a5ea5 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:46:32 -0500 Subject: [PATCH 31/55] fix(ui): preserve message draft when toggling insert mode --- src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 7bae6aa..31b342b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -288,6 +288,7 @@ impl App { self.state = AppState::Dm; self.is_loading_messages = true; self.store.current_dm_messages.clear(); + self.input_text.clear(); let api_client = self.api_client.clone(); let app_tx = self.app_tx.clone(); @@ -383,7 +384,6 @@ impl App { self.set_input_mode(InputMode::Command); } Some(Action::EnterInsertMode) => { - self.input_text.clear(); self.set_input_mode(InputMode::Insert); } Some(Action::AppendCharacter(c)) => { From e47f2fa17cfd01368ade8e2e74490c0655ce15e5 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:54:04 -0500 Subject: [PATCH 32/55] feat(ui): implement cursor positioning and vim navigation in message input --- src/action.rs | 1 + src/app.rs | 45 +++++++++++++++++++++++++++++++++++++++++-- src/input.rs | 20 +++++++++++++++++++ src/ui/dm.rs | 40 ++++++++++++++++++++++++++++++++++---- src/ui/input_token.rs | 2 +- 5 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/action.rs b/src/action.rs index 7026d34..531e98d 100644 --- a/src/action.rs +++ b/src/action.rs @@ -7,6 +7,7 @@ pub enum Action { RemoveCharacter, EnterCommandMode, EnterInsertMode, + EnterInsertModeAfter, Enter, CursorLeft, CursorRight, diff --git a/src/app.rs b/src/app.rs index 31b342b..77b2eba 100644 --- a/src/app.rs +++ b/src/app.rs @@ -48,6 +48,7 @@ pub struct AppStore { pub struct App { pub state: AppState, pub input_text: String, + pub input_cursor: usize, pub command_text: String, pub auth: Auth, pub should_quit: bool, @@ -92,6 +93,7 @@ impl App { Ok(Self { state, input_text: String::new(), + input_cursor: 0, command_text: String::new(), auth, should_quit: false, @@ -204,9 +206,11 @@ impl App { } KeyCode::Char(c) => { self.input_text.push(c); + self.input_cursor += 1; } KeyCode::Backspace => { self.input_text.pop(); + self.input_cursor = self.input_cursor.saturating_sub(1); } KeyCode::Esc => { self.should_quit = true; @@ -378,6 +382,21 @@ impl App { AppState::Dm => { let action = self.input_state.process_key_event(key); match action { + Some(Action::CursorLeft) => { + if self.input_cursor > 0 { + self.input_cursor -= 1; + } + } + Some(Action::CursorRight) => { + let max = if matches!(self.input_state.input_mode, InputMode::Insert) { + self.input_text.chars().count() + } else { + self.input_text.chars().count().saturating_sub(1) + }; + if self.input_cursor < max { + self.input_cursor += 1; + } + } Some(Action::Quit) => self.should_quit = true, Some(Action::EnterCommandMode) => { self.command_text.clear(); @@ -386,13 +405,34 @@ impl App { Some(Action::EnterInsertMode) => { self.set_input_mode(InputMode::Insert); } + Some(Action::EnterInsertModeAfter) => { + if self.input_cursor < self.input_text.chars().count() { + self.input_cursor += 1; + } + self.set_input_mode(InputMode::Insert); + } Some(Action::AppendCharacter(c)) => { - self.input_text.push(c); + let mut chars: Vec = self.input_text.chars().collect(); + if self.input_cursor <= chars.len() { + chars.insert(self.input_cursor, c); + self.input_text = chars.into_iter().collect(); + self.input_cursor += 1; + } } Some(Action::RemoveCharacter) => { - self.input_text.pop(); + if self.input_cursor > 0 { + let mut chars: Vec = self.input_text.chars().collect(); + chars.remove(self.input_cursor - 1); + self.input_text = chars.into_iter().collect(); + self.input_cursor -= 1; + } } Some(Action::Escape) => { + if matches!(self.input_state.input_mode, InputMode::Insert) + && self.input_cursor > 0 + { + self.input_cursor -= 1; + } self.set_input_mode(InputMode::UI); } Some(Action::Enter) @@ -400,6 +440,7 @@ impl App { { // TODO: Actually send the message over WS/HTTP self.input_text.clear(); + self.input_cursor = 0; self.set_input_mode(InputMode::UI); } _ => {} diff --git a/src/input.rs b/src/input.rs index cdb765c..3beefc5 100644 --- a/src/input.rs +++ b/src/input.rs @@ -44,6 +44,26 @@ impl Default for KeyMaps { vec![KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)], Action::EnterInsertMode, ), + ( + vec![KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)], + Action::EnterInsertModeAfter, + ), + ( + vec![KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)], + Action::CursorLeft, + ), + ( + vec![KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE)], + Action::CursorRight, + ), + ( + vec![KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)], + Action::CursorLeft, + ), + ( + vec![KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)], + Action::CursorRight, + ), ( vec![KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)], Action::Escape, diff --git a/src/ui/dm.rs b/src/ui/dm.rs index 80a24de..dac7447 100644 --- a/src/ui/dm.rs +++ b/src/ui/dm.rs @@ -139,11 +139,43 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { app.input_state.input_mode, crate::input::InputMode::Insert | crate::input::InputMode::UI ) { - let current_line = split_lines.last().unwrap_or(&""); - let current_line_chars = current_line.chars().count(); + let mut cursor_line_idx = 0; + let mut cursor_char_idx = 0; + let mut chars_counted = 0; + + for (i, line) in split_lines.iter().enumerate() { + let line_len = line.chars().count(); + let len_with_nl = if i == split_lines.len() - 1 { + line_len + } else { + line_len + 1 + }; + + if app.input_cursor >= chars_counted && app.input_cursor < chars_counted + len_with_nl { + cursor_line_idx = i; + cursor_char_idx = app.input_cursor - chars_counted; + break; + } else if i == split_lines.len() - 1 && app.input_cursor >= chars_counted + len_with_nl + { + cursor_line_idx = i; + cursor_char_idx = line_len; + } + chars_counted += len_with_nl; + } + + let mut base_y_offset = 0; + for line in split_lines.iter().take(cursor_line_idx) { + let chars = line.chars().count(); + if chars == 0 { + base_y_offset += 1; + } else { + base_y_offset += chars.div_ceil(text_width); + } + } - let cursor_x = input_area.x + 1 + (current_line_chars % text_width) as u16; - let cursor_y = input_area.y + 1 + (input_lines as u16).saturating_sub(1); + let cursor_x = input_area.x + 1 + (cursor_char_idx % text_width) as u16; + let cursor_y = + input_area.y + 1 + base_y_offset as u16 + (cursor_char_idx / text_width) as u16; let clamped_x = cursor_x.min(input_area.x + input_area.width.saturating_sub(2)); let clamped_y = cursor_y.min(input_area.y + input_area.height.saturating_sub(2)); diff --git a/src/ui/input_token.rs b/src/ui/input_token.rs index b858074..7b1ae2d 100644 --- a/src/ui/input_token.rs +++ b/src/ui/input_token.rs @@ -29,7 +29,7 @@ pub fn render(f: &mut Frame, app: &App) { f.render_widget(input_block, chunks[1]); f.set_cursor_position(ratatui::layout::Position::new( - chunks[1].x + 1 + app.input_text.chars().count() as u16, + chunks[1].x + 1 + app.input_cursor as u16, chunks[1].y + 1, )); } From 526169a139cd5316af88ad9e6e5f894693bde6af Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:56:15 -0500 Subject: [PATCH 33/55] feat(ui): add mode-specific border color themes --- src/input.rs | 13 ++++++++++++- src/ui/dm.rs | 13 ++----------- src/ui/dm_list.rs | 6 +----- src/ui/render.rs | 2 +- src/ui/server_list.rs | 6 +----- 5 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/input.rs b/src/input.rs index 3beefc5..d7e6aaf 100644 --- a/src/input.rs +++ b/src/input.rs @@ -4,7 +4,7 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::action::Action; -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum InputMode { #[allow(unused)] Normal, @@ -17,6 +17,17 @@ pub enum InputMode { Visual, } +impl InputMode { + pub fn color(&self) -> ratatui::style::Color { + match self { + InputMode::Normal | InputMode::UI => ratatui::style::Color::Blue, + InputMode::Insert => ratatui::style::Color::Yellow, + InputMode::Visual => ratatui::style::Color::Magenta, + InputMode::Command => ratatui::style::Color::Green, + } + } +} + struct KeyMaps { ui: HashMap, Action>, normal: HashMap, Action>, diff --git a/src/ui/dm.rs b/src/ui/dm.rs index dac7447..e10607e 100644 --- a/src/ui/dm.rs +++ b/src/ui/dm.rs @@ -7,11 +7,7 @@ use ratatui::{ }; pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { - let border_color = if matches!(app.input_state.input_mode, crate::input::InputMode::Command) { - Color::Green - } else { - Color::Reset - }; + let border_color = app.input_state.input_mode.color(); let title = if let Some(channel) = app.store.dm_channels.get(app.selected_dm_index) { format!(" Direct Message: {} ", channel.name) @@ -117,12 +113,7 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { f.render_widget(msg_paragraph, messages_area); - let input_border_color = - if matches!(app.input_state.input_mode, crate::input::InputMode::Insert) { - Color::Cyan - } else { - Color::Reset - }; + let input_border_color = app.input_state.input_mode.color(); let input_block = Block::default() .title(" Message [Visual Only - Not hooked to API] (Type 'i' to insert, ESC for normal) ") diff --git a/src/ui/dm_list.rs b/src/ui/dm_list.rs index aa5dcce..1af9d89 100644 --- a/src/ui/dm_list.rs +++ b/src/ui/dm_list.rs @@ -9,11 +9,7 @@ use ratatui::{ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { let total_items = app.store.dm_channels.len(); - let border_color = if matches!(app.input_state.input_mode, crate::input::InputMode::Command) { - Color::Green - } else { - Color::Reset - }; + let border_color = app.input_state.input_mode.color(); if app.is_loading_dms && total_items == 0 { let msg = Paragraph::new("Loading Direct Messages...") diff --git a/src/ui/render.rs b/src/ui/render.rs index 00af207..62bc988 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -44,7 +44,7 @@ pub fn render(f: &mut Frame, app: &App) { Block::default() .title(" Command ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Green)), + .border_style(Style::default().fg(app.input_state.input_mode.color())), ); f.render_widget(cmd_widget, cmd_area); diff --git a/src/ui/server_list.rs b/src/ui/server_list.rs index 6ec68f6..e144e8b 100644 --- a/src/ui/server_list.rs +++ b/src/ui/server_list.rs @@ -73,11 +73,7 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { let mut state = ListState::default(); state.select(Some(selected_index)); - let border_color = if matches!(app.input_state.input_mode, crate::input::InputMode::Command) { - Color::Green - } else { - Color::Reset - }; + let border_color = app.input_state.input_mode.color(); let list = List::new(items) .block( From 11abd28ddee04a5b5e44cefe190b80b794491648 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:04:08 -0500 Subject: [PATCH 34/55] fix(ui): prevent cursor from wrapping across line boundaries --- src/action.rs | 2 + src/app.rs | 123 +++++++++++++++++++++++++++++++++++++++++++++++--- src/input.rs | 8 ++++ 3 files changed, 126 insertions(+), 7 deletions(-) diff --git a/src/action.rs b/src/action.rs index 531e98d..05b651a 100644 --- a/src/action.rs +++ b/src/action.rs @@ -8,6 +8,8 @@ pub enum Action { EnterCommandMode, EnterInsertMode, EnterInsertModeAfter, + OpenNewLineBelow, + OpenNewLineAbove, Enter, CursorLeft, CursorRight, diff --git a/src/app.rs b/src/app.rs index 77b2eba..28d605e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -383,20 +383,98 @@ impl App { let action = self.input_state.process_key_event(key); match action { Some(Action::CursorLeft) => { - if self.input_cursor > 0 { + let chars: Vec = self.input_text.chars().collect(); + if self.input_cursor > 0 && chars.get(self.input_cursor - 1) != Some(&'\n') + { self.input_cursor -= 1; } } Some(Action::CursorRight) => { - let max = if matches!(self.input_state.input_mode, InputMode::Insert) { - self.input_text.chars().count() - } else { - self.input_text.chars().count().saturating_sub(1) + let chars: Vec = self.input_text.chars().collect(); + let max_for_line = { + let mut end = self.input_cursor; + while end < chars.len() && chars[end] != '\n' { + end += 1; + } + if matches!(self.input_state.input_mode, InputMode::Insert) { + end + } else { + if end > 0 && chars.get(end - 1) != Some(&'\n') { + end - 1 + } else { + end + } + } }; - if self.input_cursor < max { + if self.input_cursor < max_for_line { self.input_cursor += 1; } } + Some(Action::CursorUp) => { + let chars: Vec = self.input_text.chars().collect(); + let mut line_start = 0; + for i in (0..self.input_cursor).rev() { + if chars.get(i) == Some(&'\n') { + line_start = i + 1; + break; + } + } + if line_start > 0 { + let col = self.input_cursor - line_start; + let mut prev_line_start = 0; + for i in (0..line_start - 1).rev() { + if chars.get(i) == Some(&'\n') { + prev_line_start = i + 1; + break; + } + } + let prev_line_len = (line_start - 1) - prev_line_start; + + let is_normal = matches!(self.input_state.input_mode, InputMode::UI); + let max_col = if is_normal && prev_line_len > 0 { + prev_line_len - 1 + } else { + prev_line_len + }; + + self.input_cursor = prev_line_start + col.min(max_col); + } + } + Some(Action::CursorDown) => { + let chars: Vec = self.input_text.chars().collect(); + let mut line_start = 0; + for i in (0..self.input_cursor).rev() { + if chars.get(i) == Some(&'\n') { + line_start = i + 1; + break; + } + } + let col = self.input_cursor - line_start; + + let mut next_line_start = None; + for (i, c) in chars.iter().enumerate().skip(self.input_cursor) { + if *c == '\n' { + next_line_start = Some(i + 1); + break; + } + } + if let Some(start) = next_line_start { + let mut next_line_len = 0; + for c in chars.iter().skip(start) { + if *c == '\n' { + break; + } + next_line_len += 1; + } + let is_normal = matches!(self.input_state.input_mode, InputMode::UI); + let max_col = if is_normal && next_line_len > 0 { + next_line_len - 1 + } else { + next_line_len + }; + self.input_cursor = start + col.min(max_col); + } + } Some(Action::Quit) => self.should_quit = true, Some(Action::EnterCommandMode) => { self.command_text.clear(); @@ -411,6 +489,34 @@ impl App { } self.set_input_mode(InputMode::Insert); } + Some(Action::OpenNewLineBelow) => { + let mut chars: Vec = self.input_text.chars().collect(); + let mut insert_idx = chars.len(); + for (i, c) in chars.iter().enumerate().skip(self.input_cursor) { + if *c == '\n' { + insert_idx = i; + break; + } + } + chars.insert(insert_idx, '\n'); + self.input_text = chars.into_iter().collect(); + self.input_cursor = insert_idx + 1; + self.set_input_mode(InputMode::Insert); + } + Some(Action::OpenNewLineAbove) => { + let mut chars: Vec = self.input_text.chars().collect(); + let mut insert_idx = 0; + for i in (0..self.input_cursor).rev() { + if chars.get(i) == Some(&'\n') { + insert_idx = i + 1; + break; + } + } + chars.insert(insert_idx, '\n'); + self.input_text = chars.into_iter().collect(); + self.input_cursor = insert_idx; + self.set_input_mode(InputMode::Insert); + } Some(Action::AppendCharacter(c)) => { let mut chars: Vec = self.input_text.chars().collect(); if self.input_cursor <= chars.len() { @@ -431,7 +537,10 @@ impl App { if matches!(self.input_state.input_mode, InputMode::Insert) && self.input_cursor > 0 { - self.input_cursor -= 1; + let chars: Vec = self.input_text.chars().collect(); + if chars.get(self.input_cursor - 1) != Some(&'\n') { + self.input_cursor -= 1; + } } self.set_input_mode(InputMode::UI); } diff --git a/src/input.rs b/src/input.rs index d7e6aaf..010acfa 100644 --- a/src/input.rs +++ b/src/input.rs @@ -59,6 +59,14 @@ impl Default for KeyMaps { vec![KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)], Action::EnterInsertModeAfter, ), + ( + vec![KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)], + Action::OpenNewLineBelow, + ), + ( + vec![KeyEvent::new(KeyCode::Char('O'), KeyModifiers::SHIFT)], + Action::OpenNewLineAbove, + ), ( vec![KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE)], Action::CursorLeft, From dcb501b23c369fc1f650f9888719e715e50ccdc3 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:08:57 -0500 Subject: [PATCH 35/55] refactor(app): modularize state action handlers into separate files --- src/app.rs | 411 +----------------------------------- src/handlers/command.rs | 27 +++ src/handlers/dm.rs | 173 +++++++++++++++ src/handlers/dm_list.rs | 119 +++++++++++ src/handlers/error.rs | 9 + src/handlers/input_token.rs | 43 ++++ src/handlers/logged_in.rs | 56 +++++ src/handlers/mod.rs | 6 + src/main.rs | 1 + 9 files changed, 442 insertions(+), 403 deletions(-) create mode 100644 src/handlers/command.rs create mode 100644 src/handlers/dm.rs create mode 100644 src/handlers/dm_list.rs create mode 100644 src/handlers/error.rs create mode 100644 src/handlers/input_token.rs create mode 100644 src/handlers/logged_in.rs create mode 100644 src/handlers/mod.rs diff --git a/src/app.rs b/src/app.rs index 28d605e..62eea9d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,14 +2,13 @@ use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use log::{debug, error, info, warn}; -use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::crossterm::event::KeyEvent; use tokio::sync::Mutex; use tokio::sync::mpsc::{self, Receiver, Sender}; use tokio::time; use crate::{ Result, - action::Action, api::{ API_BASE_URL, auth::Auth, @@ -18,7 +17,6 @@ use crate::{ ws::WsClient, }, cache::CacheStore, - command::Command, input::{InputMode, InputState}, models::{DirectMessageChannel, Server}, }; @@ -156,410 +154,17 @@ impl App { pub async fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> { if matches!(self.input_state.input_mode, InputMode::Command) { - let action = self.input_state.process_key_event(key); - match action { - Some(Action::AppendCharacter(c)) => { - self.command_text.push(c); - } - Some(Action::RemoveCharacter) => { - self.command_text.pop(); - } - Some(Action::Escape) => { - self.command_text.clear(); - self.set_input_mode(InputMode::UI); - } - Some(Action::Enter) => { - if let Some(cmd) = Command::parse(&self.command_text) { - cmd.execute(self); - } - self.command_text.clear(); - self.set_input_mode(InputMode::UI); - } - _ => {} - } + crate::handlers::command::handle(self, key); return Ok(()); } + match self.state { - AppState::InputToken => match key.code { - KeyCode::Enter => { - if !self.input_text.is_empty() { - self.state = AppState::ValidatingToken; - match self - .auth - .validate_token(&self.input_text, Some(self.api_base_url.clone())) - .await - { - Ok(client) => match self.auth.store_token(&self.input_text).await { - Ok(_) => { - self.api_client = client; - self.state = AppState::LoggedIn; - } - Err(detailed_err) => { - self.state = AppState::Error(detailed_err); - } - }, - Err(e) => { - self.state = AppState::Error(e); - } - } - } - } - KeyCode::Char(c) => { - self.input_text.push(c); - self.input_cursor += 1; - } - KeyCode::Backspace => { - self.input_text.pop(); - self.input_cursor = self.input_cursor.saturating_sub(1); - } - KeyCode::Esc => { - self.should_quit = true; - } - _ => {} - }, + AppState::InputToken => crate::handlers::input_token::handle(self, key).await, AppState::ValidatingToken => {} - AppState::LoggedIn => { - let action = self.input_state.process_key_event(key); - match action { - Some(Action::Quit) => self.should_quit = true, - Some(Action::EnterCommandMode) => { - self.command_text.clear(); - self.set_input_mode(InputMode::Command); - } - Some(Action::Enter) => { - if self.selected_index == 0 { - self.selected_dm_index = 0; - self.state = AppState::DmList; - self.is_loading_dms = true; - - let users = self.store.users.clone(); - let api_client = self.api_client.clone(); - let app_tx = self.app_tx.clone(); - - tokio::spawn(async move { - match crate::api::dm::fetch_dms(&api_client, &users).await { - Ok((dms, new_users)) => { - app_tx.send(AppEvent::DmsLoaded(dms, new_users)).await.ok(); - } - Err(e) => { - error!("Error fetching DMs in background: {e}"); - } - } - }); - } - } - Some(Action::CursorUp) => { - if self.selected_index > 0 { - self.selected_index -= 1; - } - } - Some(Action::CursorDown) => { - let total_items = 1 + self.store.servers.len(); - if total_items > 0 && self.selected_index + 1 < total_items { - self.selected_index += 1; - } - } - Some(Action::GoToTopUI) => { - self.selected_index = 0; - } - _ => {} - } - } - AppState::DmList => { - let action = self.input_state.process_key_event(key); - match action { - Some(Action::Quit) => self.should_quit = true, - Some(Action::EnterCommandMode) => { - self.command_text.clear(); - self.set_input_mode(InputMode::Command); - } - Some(Action::CursorUp) => { - if self.selected_dm_index > 0 { - self.selected_dm_index -= 1; - } - } - Some(Action::CursorDown) => { - let total_items = self.store.dm_channels.len(); - if total_items > 0 && self.selected_dm_index + 1 < total_items { - self.selected_dm_index += 1; - } - } - Some(Action::GoToTopUI) => { - self.selected_dm_index = 0; - } - Some(Action::Enter) if !self.store.dm_channels.is_empty() => { - let channel_id = self.store.dm_channels[self.selected_dm_index].id.clone(); - self.state = AppState::Dm; - self.is_loading_messages = true; - self.store.current_dm_messages.clear(); - self.input_text.clear(); - - let api_client = self.api_client.clone(); - let app_tx = self.app_tx.clone(); - let users = self.store.users.clone(); - - tokio::spawn(async move { - let query = crate::api::channel::MessageHistoryQuery { - limit: Some(50), - before: None, - after: None, - sort: None, - nearby: None, - }; - match crate::api::channel::fetch_message_history( - &api_client, - &channel_id, - Some(&query), - ) - .await - { - Ok(messages_json) => { - let mut parsed_messages = - Vec::with_capacity(messages_json.len()); - - for msg in messages_json { - let id = msg - .get("_id") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - - let author_id = msg - .get("author") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown") - .to_string(); - - let mut author_name = author_id.clone(); - if let Some(user) = users.get(&author_id) { - author_name = user.username.clone(); - } - - let content = if let Some(content_val) = - msg.get("content").and_then(|v| v.as_str()) - { - content_val.to_string() - } else if let Some(sys) = msg.get("system") { - format!( - "[System message: {}]", - sys.get("type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - ) - } else { - "[Unsupported message]".to_string() - }; - - parsed_messages.push(crate::models::Message { - id, - author_id, - author_name, - content, - }); - } - - app_tx - .send(AppEvent::DmMessagesLoaded( - parsed_messages, - Vec::new(), - )) - .await - .ok(); - } - Err(e) => { - error!("Error fetching messages: {e}"); - app_tx - .send(AppEvent::DmMessagesLoaded(Vec::new(), Vec::new())) - .await - .ok(); - } - } - }); - } - _ => {} - } - } - AppState::Dm => { - let action = self.input_state.process_key_event(key); - match action { - Some(Action::CursorLeft) => { - let chars: Vec = self.input_text.chars().collect(); - if self.input_cursor > 0 && chars.get(self.input_cursor - 1) != Some(&'\n') - { - self.input_cursor -= 1; - } - } - Some(Action::CursorRight) => { - let chars: Vec = self.input_text.chars().collect(); - let max_for_line = { - let mut end = self.input_cursor; - while end < chars.len() && chars[end] != '\n' { - end += 1; - } - if matches!(self.input_state.input_mode, InputMode::Insert) { - end - } else { - if end > 0 && chars.get(end - 1) != Some(&'\n') { - end - 1 - } else { - end - } - } - }; - if self.input_cursor < max_for_line { - self.input_cursor += 1; - } - } - Some(Action::CursorUp) => { - let chars: Vec = self.input_text.chars().collect(); - let mut line_start = 0; - for i in (0..self.input_cursor).rev() { - if chars.get(i) == Some(&'\n') { - line_start = i + 1; - break; - } - } - if line_start > 0 { - let col = self.input_cursor - line_start; - let mut prev_line_start = 0; - for i in (0..line_start - 1).rev() { - if chars.get(i) == Some(&'\n') { - prev_line_start = i + 1; - break; - } - } - let prev_line_len = (line_start - 1) - prev_line_start; - - let is_normal = matches!(self.input_state.input_mode, InputMode::UI); - let max_col = if is_normal && prev_line_len > 0 { - prev_line_len - 1 - } else { - prev_line_len - }; - - self.input_cursor = prev_line_start + col.min(max_col); - } - } - Some(Action::CursorDown) => { - let chars: Vec = self.input_text.chars().collect(); - let mut line_start = 0; - for i in (0..self.input_cursor).rev() { - if chars.get(i) == Some(&'\n') { - line_start = i + 1; - break; - } - } - let col = self.input_cursor - line_start; - - let mut next_line_start = None; - for (i, c) in chars.iter().enumerate().skip(self.input_cursor) { - if *c == '\n' { - next_line_start = Some(i + 1); - break; - } - } - if let Some(start) = next_line_start { - let mut next_line_len = 0; - for c in chars.iter().skip(start) { - if *c == '\n' { - break; - } - next_line_len += 1; - } - let is_normal = matches!(self.input_state.input_mode, InputMode::UI); - let max_col = if is_normal && next_line_len > 0 { - next_line_len - 1 - } else { - next_line_len - }; - self.input_cursor = start + col.min(max_col); - } - } - Some(Action::Quit) => self.should_quit = true, - Some(Action::EnterCommandMode) => { - self.command_text.clear(); - self.set_input_mode(InputMode::Command); - } - Some(Action::EnterInsertMode) => { - self.set_input_mode(InputMode::Insert); - } - Some(Action::EnterInsertModeAfter) => { - if self.input_cursor < self.input_text.chars().count() { - self.input_cursor += 1; - } - self.set_input_mode(InputMode::Insert); - } - Some(Action::OpenNewLineBelow) => { - let mut chars: Vec = self.input_text.chars().collect(); - let mut insert_idx = chars.len(); - for (i, c) in chars.iter().enumerate().skip(self.input_cursor) { - if *c == '\n' { - insert_idx = i; - break; - } - } - chars.insert(insert_idx, '\n'); - self.input_text = chars.into_iter().collect(); - self.input_cursor = insert_idx + 1; - self.set_input_mode(InputMode::Insert); - } - Some(Action::OpenNewLineAbove) => { - let mut chars: Vec = self.input_text.chars().collect(); - let mut insert_idx = 0; - for i in (0..self.input_cursor).rev() { - if chars.get(i) == Some(&'\n') { - insert_idx = i + 1; - break; - } - } - chars.insert(insert_idx, '\n'); - self.input_text = chars.into_iter().collect(); - self.input_cursor = insert_idx; - self.set_input_mode(InputMode::Insert); - } - Some(Action::AppendCharacter(c)) => { - let mut chars: Vec = self.input_text.chars().collect(); - if self.input_cursor <= chars.len() { - chars.insert(self.input_cursor, c); - self.input_text = chars.into_iter().collect(); - self.input_cursor += 1; - } - } - Some(Action::RemoveCharacter) => { - if self.input_cursor > 0 { - let mut chars: Vec = self.input_text.chars().collect(); - chars.remove(self.input_cursor - 1); - self.input_text = chars.into_iter().collect(); - self.input_cursor -= 1; - } - } - Some(Action::Escape) => { - if matches!(self.input_state.input_mode, InputMode::Insert) - && self.input_cursor > 0 - { - let chars: Vec = self.input_text.chars().collect(); - if chars.get(self.input_cursor - 1) != Some(&'\n') { - self.input_cursor -= 1; - } - } - self.set_input_mode(InputMode::UI); - } - Some(Action::Enter) - if matches!(self.input_state.input_mode, InputMode::Insert) => - { - // TODO: Actually send the message over WS/HTTP - self.input_text.clear(); - self.input_cursor = 0; - self.set_input_mode(InputMode::UI); - } - _ => {} - } - } - AppState::Error(_) => { - if matches!(key.code, KeyCode::Char(_) | KeyCode::Esc | KeyCode::Enter) { - self.state = AppState::InputToken; - } - } + AppState::LoggedIn => crate::handlers::logged_in::handle(self, key), + AppState::DmList => crate::handlers::dm_list::handle(self, key), + AppState::Dm => crate::handlers::dm::handle(self, key), + AppState::Error(_) => crate::handlers::error::handle(self, key), } Ok(()) } diff --git a/src/handlers/command.rs b/src/handlers/command.rs new file mode 100644 index 0000000..e0239a5 --- /dev/null +++ b/src/handlers/command.rs @@ -0,0 +1,27 @@ +use ratatui::crossterm::event::KeyEvent; + +use crate::{action::Action, app::App, command::Command, input::InputMode}; + +pub fn handle(app: &mut App, key: KeyEvent) { + let action = app.input_state.process_key_event(key); + match action { + Some(Action::AppendCharacter(c)) => { + app.command_text.push(c); + } + Some(Action::RemoveCharacter) => { + app.command_text.pop(); + } + Some(Action::Escape) => { + app.command_text.clear(); + app.set_input_mode(InputMode::UI); + } + Some(Action::Enter) => { + if let Some(cmd) = Command::parse(&app.command_text) { + cmd.execute(app); + } + app.command_text.clear(); + app.set_input_mode(InputMode::UI); + } + _ => {} + } +} diff --git a/src/handlers/dm.rs b/src/handlers/dm.rs new file mode 100644 index 0000000..4aaee9a --- /dev/null +++ b/src/handlers/dm.rs @@ -0,0 +1,173 @@ +use ratatui::crossterm::event::KeyEvent; + +use crate::{action::Action, app::App, input::InputMode}; + +pub fn handle(app: &mut App, key: KeyEvent) { + let action = app.input_state.process_key_event(key); + match action { + Some(Action::CursorLeft) => { + let chars: Vec = app.input_text.chars().collect(); + if app.input_cursor > 0 && chars.get(app.input_cursor - 1) != Some(&'\n') { + app.input_cursor -= 1; + } + } + Some(Action::CursorRight) => { + let chars: Vec = app.input_text.chars().collect(); + let max_for_line = { + let mut end = app.input_cursor; + while end < chars.len() && chars[end] != '\n' { + end += 1; + } + if matches!(app.input_state.input_mode, InputMode::Insert) { + end + } else if end > 0 && chars.get(end - 1) != Some(&'\n') { + end - 1 + } else { + end + } + }; + if app.input_cursor < max_for_line { + app.input_cursor += 1; + } + } + Some(Action::CursorUp) => { + let chars: Vec = app.input_text.chars().collect(); + let mut line_start = 0; + for i in (0..app.input_cursor).rev() { + if chars.get(i) == Some(&'\n') { + line_start = i + 1; + break; + } + } + if line_start > 0 { + let col = app.input_cursor - line_start; + let mut prev_line_start = 0; + for i in (0..line_start - 1).rev() { + if chars.get(i) == Some(&'\n') { + prev_line_start = i + 1; + break; + } + } + let prev_line_len = (line_start - 1) - prev_line_start; + + let is_normal = matches!(app.input_state.input_mode, InputMode::UI); + let max_col = if is_normal && prev_line_len > 0 { + prev_line_len - 1 + } else { + prev_line_len + }; + + app.input_cursor = prev_line_start + col.min(max_col); + } + } + Some(Action::CursorDown) => { + let chars: Vec = app.input_text.chars().collect(); + let mut line_start = 0; + for i in (0..app.input_cursor).rev() { + if chars.get(i) == Some(&'\n') { + line_start = i + 1; + break; + } + } + let col = app.input_cursor - line_start; + + let mut next_line_start = None; + for (i, c) in chars.iter().enumerate().skip(app.input_cursor) { + if *c == '\n' { + next_line_start = Some(i + 1); + break; + } + } + if let Some(start) = next_line_start { + let mut next_line_len = 0; + for c in chars.iter().skip(start) { + if *c == '\n' { + break; + } + next_line_len += 1; + } + let is_normal = matches!(app.input_state.input_mode, InputMode::UI); + let max_col = if is_normal && next_line_len > 0 { + next_line_len - 1 + } else { + next_line_len + }; + app.input_cursor = start + col.min(max_col); + } + } + Some(Action::Quit) => app.should_quit = true, + Some(Action::EnterCommandMode) => { + app.command_text.clear(); + app.set_input_mode(InputMode::Command); + } + Some(Action::EnterInsertMode) => { + app.set_input_mode(InputMode::Insert); + } + Some(Action::EnterInsertModeAfter) => { + if app.input_cursor < app.input_text.chars().count() { + app.input_cursor += 1; + } + app.set_input_mode(InputMode::Insert); + } + Some(Action::OpenNewLineBelow) => { + let mut chars: Vec = app.input_text.chars().collect(); + let mut insert_idx = chars.len(); + for (i, c) in chars.iter().enumerate().skip(app.input_cursor) { + if *c == '\n' { + insert_idx = i; + break; + } + } + chars.insert(insert_idx, '\n'); + app.input_text = chars.into_iter().collect(); + app.input_cursor = insert_idx + 1; + app.set_input_mode(InputMode::Insert); + } + Some(Action::OpenNewLineAbove) => { + let mut chars: Vec = app.input_text.chars().collect(); + let mut insert_idx = 0; + for i in (0..app.input_cursor).rev() { + if chars.get(i) == Some(&'\n') { + insert_idx = i + 1; + break; + } + } + chars.insert(insert_idx, '\n'); + app.input_text = chars.into_iter().collect(); + app.input_cursor = insert_idx; + app.set_input_mode(InputMode::Insert); + } + Some(Action::AppendCharacter(c)) => { + let mut chars: Vec = app.input_text.chars().collect(); + if app.input_cursor <= chars.len() { + chars.insert(app.input_cursor, c); + app.input_text = chars.into_iter().collect(); + app.input_cursor += 1; + } + } + Some(Action::RemoveCharacter) => { + if app.input_cursor > 0 { + let mut chars: Vec = app.input_text.chars().collect(); + chars.remove(app.input_cursor - 1); + app.input_text = chars.into_iter().collect(); + app.input_cursor -= 1; + } + } + Some(Action::Escape) => { + if matches!(app.input_state.input_mode, InputMode::Insert) && app.input_cursor > 0 { + let chars: Vec = app.input_text.chars().collect(); + if chars.get(app.input_cursor - 1) != Some(&'\n') { + app.input_cursor -= 1; + } + } + app.set_input_mode(InputMode::UI); + } + Some(Action::Enter) if matches!(app.input_state.input_mode, InputMode::Insert) => { + // TODO: Actually send the message over WS/HTTP + app.input_text.clear(); + app.input_cursor = 0; + app.set_input_mode(InputMode::UI); + } + _ => {} + } +} diff --git a/src/handlers/dm_list.rs b/src/handlers/dm_list.rs new file mode 100644 index 0000000..880acdd --- /dev/null +++ b/src/handlers/dm_list.rs @@ -0,0 +1,119 @@ +use log::error; +use ratatui::crossterm::event::KeyEvent; + +use crate::{ + action::Action, + app::{App, AppEvent, AppState}, + input::InputMode, +}; + +pub fn handle(app: &mut App, key: KeyEvent) { + let action = app.input_state.process_key_event(key); + match action { + Some(Action::Quit) => app.should_quit = true, + Some(Action::EnterCommandMode) => { + app.command_text.clear(); + app.set_input_mode(InputMode::Command); + } + Some(Action::CursorUp) => { + if app.selected_dm_index > 0 { + app.selected_dm_index -= 1; + } + } + Some(Action::CursorDown) => { + let total_items = app.store.dm_channels.len(); + if total_items > 0 && app.selected_dm_index + 1 < total_items { + app.selected_dm_index += 1; + } + } + Some(Action::GoToTopUI) => { + app.selected_dm_index = 0; + } + Some(Action::Enter) if !app.store.dm_channels.is_empty() => { + let channel_id = app.store.dm_channels[app.selected_dm_index].id.clone(); + app.state = AppState::Dm; + app.is_loading_messages = true; + app.store.current_dm_messages.clear(); + app.input_text.clear(); + + let api_client = app.api_client.clone(); + let app_tx = app.app_tx.clone(); + let users = app.store.users.clone(); + + tokio::spawn(async move { + let query = crate::api::channel::MessageHistoryQuery { + limit: Some(50), + before: None, + after: None, + sort: None, + nearby: None, + }; + match crate::api::channel::fetch_message_history( + &api_client, + &channel_id, + Some(&query), + ) + .await + { + Ok(messages_json) => { + let mut parsed_messages = Vec::with_capacity(messages_json.len()); + + for msg in messages_json { + let id = msg + .get("_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let author_id = msg + .get("author") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + + let mut author_name = author_id.clone(); + if let Some(user) = users.get(&author_id) { + author_name = user.username.clone(); + } + + let content = if let Some(content_val) = + msg.get("content").and_then(|v| v.as_str()) + { + content_val.to_string() + } else if let Some(sys) = msg.get("system") { + format!( + "[System message: {}]", + sys.get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + ) + } else { + "[Unsupported message]".to_string() + }; + + parsed_messages.push(crate::models::Message { + id, + author_id, + author_name, + content, + }); + } + + app_tx + .send(AppEvent::DmMessagesLoaded(parsed_messages, Vec::new())) + .await + .ok(); + } + Err(e) => { + error!("Error fetching messages: {e}"); + app_tx + .send(AppEvent::DmMessagesLoaded(Vec::new(), Vec::new())) + .await + .ok(); + } + } + }); + } + _ => {} + } +} diff --git a/src/handlers/error.rs b/src/handlers/error.rs new file mode 100644 index 0000000..d1bb706 --- /dev/null +++ b/src/handlers/error.rs @@ -0,0 +1,9 @@ +use ratatui::crossterm::event::{KeyCode, KeyEvent}; + +use crate::app::{App, AppState}; + +pub fn handle(app: &mut App, key: KeyEvent) { + if matches!(key.code, KeyCode::Char(_) | KeyCode::Esc | KeyCode::Enter) { + app.state = AppState::InputToken; + } +} diff --git a/src/handlers/input_token.rs b/src/handlers/input_token.rs new file mode 100644 index 0000000..a424987 --- /dev/null +++ b/src/handlers/input_token.rs @@ -0,0 +1,43 @@ +use ratatui::crossterm::event::{KeyCode, KeyEvent}; + +use crate::app::{App, AppState}; + +pub async fn handle(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Enter => { + if !app.input_text.is_empty() { + app.state = AppState::ValidatingToken; + match app + .auth + .validate_token(&app.input_text, Some(app.api_base_url.clone())) + .await + { + Ok(client) => match app.auth.store_token(&app.input_text).await { + Ok(_) => { + app.api_client = client; + app.state = AppState::LoggedIn; + } + Err(detailed_err) => { + app.state = AppState::Error(detailed_err); + } + }, + Err(e) => { + app.state = AppState::Error(e); + } + } + } + } + KeyCode::Char(c) => { + app.input_text.push(c); + app.input_cursor += 1; + } + KeyCode::Backspace => { + app.input_text.pop(); + app.input_cursor = app.input_cursor.saturating_sub(1); + } + KeyCode::Esc => { + app.should_quit = true; + } + _ => {} + } +} diff --git a/src/handlers/logged_in.rs b/src/handlers/logged_in.rs new file mode 100644 index 0000000..2c8541d --- /dev/null +++ b/src/handlers/logged_in.rs @@ -0,0 +1,56 @@ +use log::error; +use ratatui::crossterm::event::KeyEvent; + +use crate::{ + action::Action, + app::{App, AppEvent, AppState}, + input::InputMode, +}; + +pub fn handle(app: &mut App, key: KeyEvent) { + let action = app.input_state.process_key_event(key); + match action { + Some(Action::Quit) => app.should_quit = true, + Some(Action::EnterCommandMode) => { + app.command_text.clear(); + app.set_input_mode(InputMode::Command); + } + Some(Action::Enter) => { + if app.selected_index == 0 { + app.selected_dm_index = 0; + app.state = AppState::DmList; + app.is_loading_dms = true; + + let users = app.store.users.clone(); + let api_client = app.api_client.clone(); + let app_tx = app.app_tx.clone(); + + tokio::spawn(async move { + match crate::api::dm::fetch_dms(&api_client, &users).await { + Ok((dms, new_users)) => { + app_tx.send(AppEvent::DmsLoaded(dms, new_users)).await.ok(); + } + Err(e) => { + error!("Error fetching DMs in background: {e}"); + } + } + }); + } + } + Some(Action::CursorUp) => { + if app.selected_index > 0 { + app.selected_index -= 1; + } + } + Some(Action::CursorDown) => { + let total_items = 1 + app.store.servers.len(); + if total_items > 0 && app.selected_index + 1 < total_items { + app.selected_index += 1; + } + } + Some(Action::GoToTopUI) => { + app.selected_index = 0; + } + _ => {} + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..333f330 --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,6 @@ +pub mod command; +pub mod dm; +pub mod dm_list; +pub mod error; +pub mod input_token; +pub mod logged_in; diff --git a/src/main.rs b/src/main.rs index 50b28f8..135fab8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod app; mod cache; mod command; mod error; +mod handlers; mod input; mod models; mod notification; From 4ea75ced664cf9c9e0d34956cf85039706fd48e9 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:12:12 -0500 Subject: [PATCH 36/55] feat(input): add in-memory yank buffer and normal mode `dd` motion --- src/action.rs | 1 + src/app.rs | 2 ++ src/handlers/dm.rs | 54 ++++++++++++++++++++++++++++++++++++++++++++++ src/input.rs | 7 ++++++ 4 files changed, 64 insertions(+) diff --git a/src/action.rs b/src/action.rs index 05b651a..3cc317a 100644 --- a/src/action.rs +++ b/src/action.rs @@ -10,6 +10,7 @@ pub enum Action { EnterInsertModeAfter, OpenNewLineBelow, OpenNewLineAbove, + DeleteLine, Enter, CursorLeft, CursorRight, diff --git a/src/app.rs b/src/app.rs index 62eea9d..00c8c94 100644 --- a/src/app.rs +++ b/src/app.rs @@ -47,6 +47,7 @@ pub struct App { pub state: AppState, pub input_text: String, pub input_cursor: usize, + pub yank_buffer: Option, pub command_text: String, pub auth: Auth, pub should_quit: bool, @@ -92,6 +93,7 @@ impl App { state, input_text: String::new(), input_cursor: 0, + yank_buffer: None, command_text: String::new(), auth, should_quit: false, diff --git a/src/handlers/dm.rs b/src/handlers/dm.rs index 4aaee9a..0c8c09b 100644 --- a/src/handlers/dm.rs +++ b/src/handlers/dm.rs @@ -137,6 +137,60 @@ pub fn handle(app: &mut App, key: KeyEvent) { app.input_cursor = insert_idx; app.set_input_mode(InputMode::Insert); } + Some(Action::DeleteLine) => { + let chars: Vec = app.input_text.chars().collect(); + if !chars.is_empty() { + let mut line_start = 0; + for i in (0..app.input_cursor).rev() { + if chars.get(i) == Some(&'\n') { + line_start = i + 1; + break; + } + } + + let mut line_end = chars.len(); + for (i, c) in chars.iter().enumerate().skip(app.input_cursor) { + if *c == '\n' { + line_end = i; + break; + } + } + + let mut delete_start = line_start; + let mut delete_end = line_end; + + if line_end < chars.len() && chars[line_end] == '\n' { + delete_end += 1; + } else if line_start > 0 && chars[line_start - 1] == '\n' { + delete_start -= 1; + } + + let yank_content: String = chars[line_start..line_end].iter().collect(); + app.yank_buffer = Some(format!("{}\n", yank_content)); + + let mut new_chars = Vec::new(); + new_chars.extend_from_slice(&chars[0..delete_start]); + new_chars.extend_from_slice(&chars[delete_end..chars.len()]); + + app.input_text = new_chars.into_iter().collect(); + + let chars_after: Vec = app.input_text.chars().collect(); + if chars_after.is_empty() { + app.input_cursor = 0; + } else if delete_start < chars_after.len() { + app.input_cursor = delete_start; + } else { + let mut new_start = 0; + for i in (0..chars_after.len()).rev() { + if chars_after[i] == '\n' { + new_start = i + 1; + break; + } + } + app.input_cursor = new_start; + } + } + } Some(Action::AppendCharacter(c)) => { let mut chars: Vec = app.input_text.chars().collect(); if app.input_cursor <= chars.len() { diff --git a/src/input.rs b/src/input.rs index 010acfa..32062ee 100644 --- a/src/input.rs +++ b/src/input.rs @@ -59,6 +59,13 @@ impl Default for KeyMaps { vec![KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)], Action::EnterInsertModeAfter, ), + ( + vec![ + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), + ], + Action::DeleteLine, + ), ( vec![KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)], Action::OpenNewLineBelow, From 8c1e4a664d55cc36dbafc48d59bca2fe3ea34a59 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:13:51 -0500 Subject: [PATCH 37/55] feat(input): add `A` and `I` insert mode motions --- src/action.rs | 2 ++ src/handlers/dm.rs | 24 ++++++++++++++++++++++++ src/input.rs | 8 ++++++++ 3 files changed, 34 insertions(+) diff --git a/src/action.rs b/src/action.rs index 3cc317a..04a1c96 100644 --- a/src/action.rs +++ b/src/action.rs @@ -8,6 +8,8 @@ pub enum Action { EnterCommandMode, EnterInsertMode, EnterInsertModeAfter, + EnterInsertModeLineStart, + EnterInsertModeLineEnd, OpenNewLineBelow, OpenNewLineAbove, DeleteLine, diff --git a/src/handlers/dm.rs b/src/handlers/dm.rs index 0c8c09b..0c380f6 100644 --- a/src/handlers/dm.rs +++ b/src/handlers/dm.rs @@ -109,6 +109,30 @@ pub fn handle(app: &mut App, key: KeyEvent) { } app.set_input_mode(InputMode::Insert); } + Some(Action::EnterInsertModeLineStart) => { + let chars: Vec = app.input_text.chars().collect(); + let mut line_start = 0; + for i in (0..app.input_cursor).rev() { + if chars.get(i) == Some(&'\n') { + line_start = i + 1; + break; + } + } + app.input_cursor = line_start; + app.set_input_mode(InputMode::Insert); + } + Some(Action::EnterInsertModeLineEnd) => { + let chars: Vec = app.input_text.chars().collect(); + let mut line_end = chars.len(); + for (i, c) in chars.iter().enumerate().skip(app.input_cursor) { + if *c == '\n' { + line_end = i; + break; + } + } + app.input_cursor = line_end; + app.set_input_mode(InputMode::Insert); + } Some(Action::OpenNewLineBelow) => { let mut chars: Vec = app.input_text.chars().collect(); let mut insert_idx = chars.len(); diff --git a/src/input.rs b/src/input.rs index 32062ee..dc7d092 100644 --- a/src/input.rs +++ b/src/input.rs @@ -59,6 +59,14 @@ impl Default for KeyMaps { vec![KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)], Action::EnterInsertModeAfter, ), + ( + vec![KeyEvent::new(KeyCode::Char('I'), KeyModifiers::SHIFT)], + Action::EnterInsertModeLineStart, + ), + ( + vec![KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT)], + Action::EnterInsertModeLineEnd, + ), ( vec![ KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), From 022f9bb94c1dcad72c3b73afcfacd1d61eff0b0d Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:37:59 -0500 Subject: [PATCH 38/55] fix(ui): resolve usernames for message authors in DMs --- src/handlers/dm_list.rs | 20 ++++++++++++++++++-- src/main.rs | 15 ++++++--------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/handlers/dm_list.rs b/src/handlers/dm_list.rs index 880acdd..ab086d0 100644 --- a/src/handlers/dm_list.rs +++ b/src/handlers/dm_list.rs @@ -57,6 +57,8 @@ pub fn handle(app: &mut App, key: KeyEvent) { { Ok(messages_json) => { let mut parsed_messages = Vec::with_capacity(messages_json.len()); + let mut new_users_fetched = Vec::new(); + let mut local_users = users.clone(); for msg in messages_json { let id = msg @@ -72,8 +74,22 @@ pub fn handle(app: &mut App, key: KeyEvent) { .to_string(); let mut author_name = author_id.clone(); - if let Some(user) = users.get(&author_id) { + if let Some(user) = local_users.get(&author_id) { author_name = user.username.clone(); + } else if author_id != "Unknown" { + if let Ok(user_val) = api_client + .get::(crate::api::client::Endpoint::User(author_id.clone())) + .await + && let Some(username) = user_val.get("username").and_then(|v| v.as_str()) + { + author_name = username.to_string(); + let new_user = crate::models::User { + id: author_id.clone(), + username: username.to_string(), + }; + local_users.insert(author_id.clone(), new_user.clone()); + new_users_fetched.push(new_user); + } } let content = if let Some(content_val) = @@ -100,7 +116,7 @@ pub fn handle(app: &mut App, key: KeyEvent) { } app_tx - .send(AppEvent::DmMessagesLoaded(parsed_messages, Vec::new())) + .send(AppEvent::DmMessagesLoaded(parsed_messages, new_users_fetched)) .await .ok(); } diff --git a/src/main.rs b/src/main.rs index 135fab8..5d57e36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,16 +80,13 @@ async fn main() -> anyhow::Result<(), Box> { ) && let Ok(uid) = Id::::new(my_id) { + let user = crate::models::User { + id: my_id.to_string(), + username: my_username.to_string(), + }; let mut cache_locked = app.cache.lock().await; - cache_locked - .set( - uid, - &crate::models::User { - id: my_id.to_string(), - username: my_username.to_string(), - }, - ) - .ok(); + cache_locked.set(uid, &user).ok(); + app.store.users.insert(user.id.clone(), user); } /* This is an example, for now we have no use for notifications */ From 4ee7d9530bc5a449461499fda7fef11e13284f93 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:01:28 -0500 Subject: [PATCH 39/55] feat(ws): listen for real-time messages and resolve author details --- src/app.rs | 16 ++++++++++++++++ src/main.rs | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/app.rs b/src/app.rs index 00c8c94..6f5f121 100644 --- a/src/app.rs +++ b/src/app.rs @@ -24,6 +24,11 @@ use crate::{ pub enum AppEvent { DmsLoaded(Vec, Vec), DmMessagesLoaded(Vec, Vec), + NewMessage { + channel_id: String, + message: crate::models::Message, + new_user: Option, + }, } pub enum AppState { @@ -132,6 +137,17 @@ impl App { self.store.current_dm_messages = messages; self.is_loading_messages = false; } + AppEvent::NewMessage { channel_id, message, new_user } => { + if let Some(user) = new_user { + self.store.users.insert(user.id.clone(), user); + } + // Only append if it belongs to the currently viewed channel + if matches!(self.state, AppState::Dm) + && self.store.dm_channels.get(self.selected_dm_index).map(|c| &c.id) == Some(&channel_id) + { + self.store.current_dm_messages.insert(0, message); // newest is at 0 (rev order in UI) + } + } } } diff --git a/src/main.rs b/src/main.rs index 5d57e36..2fb291f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -137,6 +137,57 @@ async fn main() -> anyhow::Result<(), Box> { if let Ok(event) = app.ws_rx.try_recv() { debug!("Received WebSocket event: {event:?}"); api::ws::EventHandler::new(&mut app.store.servers).handle_event(&event); + + if let crate::api::events::ServerEvent::Message(msg_val) = event { + let api_client = app.api_client.clone(); + let app_tx = app.app_tx.clone(); + let local_users = app.store.users.clone(); + + tokio::spawn(async move { + if let Some(channel_id) = msg_val.get("channel").and_then(|v| v.as_str()) { + let id = msg_val.get("_id").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); + let author_id = msg_val.get("author").and_then(|v| v.as_str()).unwrap_or("Unknown").to_string(); + let channel_id = channel_id.to_string(); + + let mut author_name = author_id.clone(); + let mut new_user_fetched = None; + + if let Some(user) = local_users.get(&author_id) { + author_name = user.username.clone(); + } else if author_id != "Unknown" { + if let Ok(user_val) = api_client + .get::(crate::api::client::Endpoint::User(author_id.clone())) + .await + && let Some(username) = user_val.get("username").and_then(|v| v.as_str()) + { + author_name = username.to_string(); + let new_user = crate::models::User { + id: author_id.clone(), + username: username.to_string(), + }; + new_user_fetched = Some(new_user); + } + } + + let content = if let Some(content_val) = msg_val.get("content").and_then(|v| v.as_str()) { + content_val.to_string() + } else if let Some(sys) = msg_val.get("system") { + format!("[System message: {}]", sys.get("type").and_then(|v| v.as_str()).unwrap_or("unknown")) + } else { + "[Unsupported message]".to_string() + }; + + let message = crate::models::Message { + id, + author_id, + author_name, + content, + }; + + app_tx.send(app::AppEvent::NewMessage { channel_id, message, new_user: new_user_fetched }).await.ok(); + } + }); + } } } From 35959ae84c9b7aa0b6d3d523abf57c72317927c1 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:06:07 -0500 Subject: [PATCH 40/55] feat(dm): add unread indicators and message previews for DM list --- src/api/dm.rs | 2 ++ src/app.rs | 18 +++++++++++++----- src/handlers/dm_list.rs | 1 + src/models.rs | 3 +++ src/ui/dm_list.rs | 19 +++++++++++++++++-- 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/api/dm.rs b/src/api/dm.rs index 5e8c42f..5fdc395 100644 --- a/src/api/dm.rs +++ b/src/api/dm.rs @@ -147,6 +147,8 @@ pub async fn fetch_dms( dm_channels.push(DirectMessageChannel { id: id_str.to_string(), name, + has_unread: false, + last_message_preview: None, }); } } diff --git a/src/app.rs b/src/app.rs index 6f5f121..32e80a6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -141,11 +141,19 @@ impl App { if let Some(user) = new_user { self.store.users.insert(user.id.clone(), user); } - // Only append if it belongs to the currently viewed channel - if matches!(self.state, AppState::Dm) - && self.store.dm_channels.get(self.selected_dm_index).map(|c| &c.id) == Some(&channel_id) - { - self.store.current_dm_messages.insert(0, message); // newest is at 0 (rev order in UI) + + let is_active_channel = matches!(self.state, AppState::Dm) + && self.store.dm_channels.get(self.selected_dm_index).map(|c| &c.id) == Some(&channel_id); + + if is_active_channel { + self.store.current_dm_messages.insert(0, message.clone()); // newest is at 0 (rev order in UI) + } + + if let Some(channel) = self.store.dm_channels.iter_mut().find(|c| c.id == channel_id) { + if !is_active_channel { + channel.has_unread = true; + } + channel.last_message_preview = Some(message.content); } } } diff --git a/src/handlers/dm_list.rs b/src/handlers/dm_list.rs index ab086d0..bb0b104 100644 --- a/src/handlers/dm_list.rs +++ b/src/handlers/dm_list.rs @@ -31,6 +31,7 @@ pub fn handle(app: &mut App, key: KeyEvent) { } Some(Action::Enter) if !app.store.dm_channels.is_empty() => { let channel_id = app.store.dm_channels[app.selected_dm_index].id.clone(); + app.store.dm_channels[app.selected_dm_index].has_unread = false; app.state = AppState::Dm; app.is_loading_messages = true; app.store.current_dm_messages.clear(); diff --git a/src/models.rs b/src/models.rs index 901334c..ba51616 100644 --- a/src/models.rs +++ b/src/models.rs @@ -11,6 +11,9 @@ pub struct Server { pub struct DirectMessageChannel { pub id: String, pub name: String, + #[serde(default)] + pub has_unread: bool, + pub last_message_preview: Option, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] diff --git a/src/ui/dm_list.rs b/src/ui/dm_list.rs index 1af9d89..a017b54 100644 --- a/src/ui/dm_list.rs +++ b/src/ui/dm_list.rs @@ -73,10 +73,25 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { Style::default().fg(Color::Cyan) }; - items.push(ListItem::new(Line::from(vec![ + let mut spans = vec![ Span::styled(line_num_str, num_style), Span::styled(channel.name.as_str(), text_style), - ]))); + ]; + + if channel.has_unread { + spans.push(Span::styled(" [*]", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))); + } + + if let Some(preview) = &channel.last_message_preview { + let mut short_preview = preview.replace('\n', " "); + if short_preview.len() > 30 { + short_preview.truncate(27); + short_preview.push_str("..."); + } + spans.push(Span::styled(format!(" - {}", short_preview), Style::default().fg(Color::DarkGray))); + } + + items.push(ListItem::new(Line::from(spans))); } let mut state = ListState::default(); From 9770db452eb0ce0d223595cf5095b5aa08e5de05 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:12:54 -0500 Subject: [PATCH 41/55] feat(dm): hook up message sending to API --- Cargo.toml | 1 + src/api/client.rs | 30 ++++++++++++++++++++++++++++++ src/handlers/dm.rs | 27 +++++++++++++++++++++++++-- src/ui/dm.rs | 2 +- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fe7ffbf..67e1244 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ reqwest = { version = "0.13.4", default-features = false, features = [ serde = { version = "1", features = ["derive"] } tokio = { version = "1.52.3", features = ["full"] } serde_json = "1.0.150" +ulid = "3.0.0" [dev-dependencies] cargo-husky = { version = "1.5.0", default-features = false, features = [ diff --git a/src/api/client.rs b/src/api/client.rs index 60d8624..ae28e27 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -77,6 +77,36 @@ impl ApiClient { } } + pub async fn post( + &self, + endpoint: Endpoint, + body: &B, + ) -> Result { + let url = format!("{}{}", self.base_url, endpoint.path()); + + let response = self + .client + .post(&url) + .header("X-Session-Token", &self.token) + .json(body) + .send() + .await?; + + if response.status().is_success() { + let data = response.json::().await?; + Ok(data) + } else { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + Err(anyhow!( + "API POST request to {:?} failed: {} - {}", + endpoint, + status, + text + )) + } + } + pub fn clone_token(&self) -> String { self.token.clone() } diff --git a/src/handlers/dm.rs b/src/handlers/dm.rs index 0c380f6..ccfd716 100644 --- a/src/handlers/dm.rs +++ b/src/handlers/dm.rs @@ -240,8 +240,31 @@ pub fn handle(app: &mut App, key: KeyEvent) { } app.set_input_mode(InputMode::UI); } - Some(Action::Enter) if matches!(app.input_state.input_mode, InputMode::Insert) => { - // TODO: Actually send the message over WS/HTTP + Some(Action::Enter) if matches!(app.input_state.input_mode, InputMode::Insert | InputMode::UI) => { + let content = app.input_text.trim().to_string(); + if !content.is_empty() { + if let Some(channel) = app.store.dm_channels.get(app.selected_dm_index) { + let channel_id = channel.id.clone(); + let api_client = app.api_client.clone(); + + tokio::spawn(async move { + #[derive(serde::Serialize)] + struct SendMessagePayload { + content: String, + nonce: String, + } + + let payload = SendMessagePayload { + content, + nonce: ulid::Ulid::generate().to_string(), + }; + + if let Err(e) = api_client.post::(crate::api::client::Endpoint::SendMessage(channel_id), &payload).await { + log::error!("Failed to send message: {}", e); + } + }); + } + } app.input_text.clear(); app.input_cursor = 0; app.set_input_mode(InputMode::UI); diff --git a/src/ui/dm.rs b/src/ui/dm.rs index e10607e..a89c9b8 100644 --- a/src/ui/dm.rs +++ b/src/ui/dm.rs @@ -116,7 +116,7 @@ pub fn render(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { let input_border_color = app.input_state.input_mode.color(); let input_block = Block::default() - .title(" Message [Visual Only - Not hooked to API] (Type 'i' to insert, ESC for normal) ") + .title(" Message (Type 'i' to insert, ESC for normal) ") .borders(Borders::ALL) .border_style(Style::default().fg(input_border_color)); From 424836c60e839c6b7b31abdff501b95c10907116 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:23:21 -0500 Subject: [PATCH 42/55] feat(ws): handle MessageUpdate and MessageDelete events --- src/app.rs | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 58 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 120 insertions(+), 11 deletions(-) diff --git a/src/app.rs b/src/app.rs index 32e80a6..75f82cf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -29,6 +29,15 @@ pub enum AppEvent { message: crate::models::Message, new_user: Option, }, + MessageUpdated { + channel_id: String, + message_id: String, + content: String, + }, + MessageDeleted { + channel_id: String, + message_id: String, + }, } pub enum AppState { @@ -137,25 +146,81 @@ impl App { self.store.current_dm_messages = messages; self.is_loading_messages = false; } - AppEvent::NewMessage { channel_id, message, new_user } => { + AppEvent::NewMessage { + channel_id, + message, + new_user, + } => { if let Some(user) = new_user { self.store.users.insert(user.id.clone(), user); } - let is_active_channel = matches!(self.state, AppState::Dm) - && self.store.dm_channels.get(self.selected_dm_index).map(|c| &c.id) == Some(&channel_id); + let is_active_channel = matches!(self.state, AppState::Dm) + && self + .store + .dm_channels + .get(self.selected_dm_index) + .map(|c| &c.id) + == Some(&channel_id); if is_active_channel { self.store.current_dm_messages.insert(0, message.clone()); // newest is at 0 (rev order in UI) } - if let Some(channel) = self.store.dm_channels.iter_mut().find(|c| c.id == channel_id) { + if let Some(channel) = self + .store + .dm_channels + .iter_mut() + .find(|c| c.id == channel_id) + { if !is_active_channel { channel.has_unread = true; } channel.last_message_preview = Some(message.content); } } + AppEvent::MessageUpdated { + channel_id, + message_id, + content, + } => { + let is_active_channel = matches!(self.state, AppState::Dm) + && self + .store + .dm_channels + .get(self.selected_dm_index) + .map(|c| &c.id) + == Some(&channel_id); + + if is_active_channel { + if let Some(msg) = self + .store + .current_dm_messages + .iter_mut() + .find(|m| m.id == message_id) + { + msg.content = content; + } + } + } + AppEvent::MessageDeleted { + channel_id, + message_id, + } => { + let is_active_channel = matches!(self.state, AppState::Dm) + && self + .store + .dm_channels + .get(self.selected_dm_index) + .map(|c| &c.id) + == Some(&channel_id); + + if is_active_channel { + self.store + .current_dm_messages + .retain(|m| m.id != message_id); + } + } } } diff --git a/src/main.rs b/src/main.rs index 2fb291f..62466da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -145,8 +145,16 @@ async fn main() -> anyhow::Result<(), Box> { tokio::spawn(async move { if let Some(channel_id) = msg_val.get("channel").and_then(|v| v.as_str()) { - let id = msg_val.get("_id").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(); - let author_id = msg_val.get("author").and_then(|v| v.as_str()).unwrap_or("Unknown").to_string(); + let id = msg_val + .get("_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let author_id = msg_val + .get("author") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); let channel_id = channel_id.to_string(); let mut author_name = author_id.clone(); @@ -156,9 +164,12 @@ async fn main() -> anyhow::Result<(), Box> { author_name = user.username.clone(); } else if author_id != "Unknown" { if let Ok(user_val) = api_client - .get::(crate::api::client::Endpoint::User(author_id.clone())) + .get::(crate::api::client::Endpoint::User( + author_id.clone(), + )) .await - && let Some(username) = user_val.get("username").and_then(|v| v.as_str()) + && let Some(username) = + user_val.get("username").and_then(|v| v.as_str()) { author_name = username.to_string(); let new_user = crate::models::User { @@ -169,10 +180,17 @@ async fn main() -> anyhow::Result<(), Box> { } } - let content = if let Some(content_val) = msg_val.get("content").and_then(|v| v.as_str()) { + let content = if let Some(content_val) = + msg_val.get("content").and_then(|v| v.as_str()) + { content_val.to_string() } else if let Some(sys) = msg_val.get("system") { - format!("[System message: {}]", sys.get("type").and_then(|v| v.as_str()).unwrap_or("unknown")) + format!( + "[System message: {}]", + sys.get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + ) } else { "[Unsupported message]".to_string() }; @@ -184,9 +202,35 @@ async fn main() -> anyhow::Result<(), Box> { content, }; - app_tx.send(app::AppEvent::NewMessage { channel_id, message, new_user: new_user_fetched }).await.ok(); + app_tx + .send(app::AppEvent::NewMessage { + channel_id, + message, + new_user: new_user_fetched, + }) + .await + .ok(); } }); + } else if let crate::api::events::ServerEvent::MessageUpdate { id, channel, data } = + &event + { + if let Some(content) = data.get("content").and_then(|v| v.as_str()) { + app.app_tx + .try_send(app::AppEvent::MessageUpdated { + channel_id: channel.clone(), + message_id: id.clone(), + content: content.to_string(), + }) + .ok(); + } + } else if let crate::api::events::ServerEvent::MessageDelete { id, channel } = &event { + app.app_tx + .try_send(app::AppEvent::MessageDeleted { + channel_id: channel.clone(), + message_id: id.clone(), + }) + .ok(); } } } From eaac987e74bb6aa8f008631a205e583b88ea4545 Mon Sep 17 00:00:00 2001 From: ih8js-git <141177946+ih8js-git@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:31:22 -0500 Subject: [PATCH 43/55] refactor(input): separate InputMode::UI and InputMode::Normal contexts --- ROADMAP.md | 557 ---------------------------------------- src/app.rs | 5 +- src/handlers/command.rs | 12 +- src/handlers/dm.rs | 31 ++- src/handlers/dm_list.rs | 13 +- src/input.rs | 184 ++++++------- src/ui/dm.rs | 2 +- src/ui/dm_list.rs | 10 +- 8 files changed, 149 insertions(+), 665 deletions(-) delete mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md deleted file mode 100644 index b43d340..0000000 --- a/ROADMAP.md +++ /dev/null @@ -1,557 +0,0 @@ -# VimStoat — Project Roadmap - -> A lightweight, Vim-flavored TUI client for [Stoat.chat](https://stoat.chat) -> Built in Rust with Ratatui. Designed to feel like home for Vim users. - ---- - -## Table of Contents - -- [Vision](#vision) -- [Architecture Decision: Pure Rust vs Hybrid](#architecture-decision-pure-rust-vs-hybrid) -- [Authentication Strategy](#authentication-strategy) -- [Project Structure](#project-structure) -- [Vim Modal System](#vim-modal-system) -- [Keybinding Reference](#keybinding-reference) -- [Roadmap Phases](#roadmap-phases) -- [API Surface We Need](#api-surface-we-need) -- [Prior Art & Inspiration](#prior-art--inspiration) - ---- - -## Vision - -VimStoat is a terminal-first Stoat chat client that treats Vim keybindings as a first-class citizen — not an afterthought bolted onto a generic TUI. The goal is a client where a Vim user can navigate servers, channels, and messages entirely from muscle memory: `j`/`k` to scroll, `i` to compose, `Esc` to stop, `/` to search, `:q` to quit. - -**What this is NOT:** - -- A bot framework (that's what `stoat-rs` is for) -- A full reimplementation of the web client -- A project that needs every feature on day one - -**What this IS:** - -- A fast, keyboard-driven chat client -- Opinionated about UX — Vim's modal paradigm applied to chat -- A single static binary with zero runtime dependencies - ---- - -## Architecture Decision: Pure Rust vs Hybrid - -There's been discussion about two possible architectures. Here's an honest breakdown. - -### Option A: Pure Rust (Recommended) - -``` -┌─────────────────────────────┐ -│ vimstoat │ -│ ┌───────────┐ ┌──────────┐ │ -│ │ Ratatui │ │ reqwest │ │ -│ │ (TUI) │ │ (HTTP) │ │ -│ └───────────┘ └──────────┘ │ -│ ┌───────────┐ ┌──────────┐ │ -│ │ crossterm │ │ tungstenite│ │ -│ │ (input) │ │ (WS) │ │ -│ └───────────┘ └──────────┘ │ -│ Single Binary │ -└─────────────────────────────┘ -``` - -**The Stoat/Revolt REST API is simple.** Looking at the actual endpoints we need (listed below in [API Surface](#api-surface-we-need)), we're talking about ~15 REST calls that are all `GET`/`POST`/`PATCH`/`DELETE` with JSON bodies. This is trivial to implement with `reqwest`. The WebSocket protocol is a single connection that sends/receives JSON events. `tokio-tungstenite` handles this cleanly. - -| Pros | Cons | -| ------------------------------------------ | --------------------------------------------------------- | -| Single binary, zero runtime deps | Must hand-write API types (or pull `revolt-models` crate) | -| No IPC overhead, no serialization boundary | WebSocket reconnection logic is on us | -| Simpler deployment (`cargo install`) | No access to JS ecosystem libraries | -| Everything is type-safe end-to-end | If API changes, we update manually | -| Tokio async works perfectly with ratatui | | -| No Node.js/npm/Bun dependency for users | | - -**Why stoat-rs isn't the answer:** The SDK is bot-centric — it uses `X-Bot-Token`, wraps everything in a `Client::new(EventHandler).run()` pattern that assumes you're building a bot, and the WebSocket layer is tightly coupled to that. For a user-facing TUI, we'd fight the SDK at every turn. However, the `revolt-models` crate (which `stoat-rs` re-exports as `stoat-models`) contains all the API type definitions and is perfectly usable standalone. - -### Option B: Rust TUI + TypeScript API Backend - -``` -┌──────────────┐ IPC ┌────────────────┐ -│ Rust TUI │◄────────────►│ Node/Bun │ -│ (Ratatui) │ JSON-RPC │ (stoat-api) │ -│ (crossterm) │ over stdin/ │ (revolt.js) │ -│ │ stdout or │ │ -│ │ unix socket │ │ -└──────────────┘ └────────────────┘ - Process A Process B -``` - -The idea: let TypeScript handle the Stoat API (since `revolt.js`/`stoat-api` are the best-maintained client libraries with full WebSocket support, caching, and state management built in), and let Rust handle the TUI rendering. - -| Pros | Cons | -| -------------------------------------------------------------- | ------------------------------------------------------------------ | -| `revolt.js` has battle-tested WS handling, caching, reactivity | **Two processes** — must manage lifecycle, crashes, zombies | -| API types are always in sync with upstream | Serialization overhead on every message/event | -| If Stoat API changes, npm update fixes it | Users need Node.js/Bun installed — kills "single binary" story | -| Richer ecosystem for API edge cases | Debugging across process boundary is painful | -| | JSON-RPC or IPC protocol is a whole sub-project to design | -| | Latency: every keypress → IPC → TS → API → response → IPC → render | -| | Massively more complex build/packaging/distribution | - -**A middle-ground variant** would be `napi-rs` (Rust as a native Node addon, running in-process). This eliminates the IPC overhead but still requires Node.js at runtime. It's great for Electron/Tauri apps but awkward for a pure TUI — you'd be embedding a Node runtime just for HTTP calls. - -### Verdict - -**Go pure Rust.** The API surface is small enough that the "better JS libraries" argument doesn't hold up against the massive complexity tax of a hybrid architecture. We'd spend more time building and debugging the IPC bridge than we would writing 15 HTTP endpoints in Rust. The `revolt-models` crate gives us the types for free, and `reqwest` + `tokio-tungstenite` cover our networking needs. - -If `revolt-models` ever becomes unmaintained, we can generate Rust types from the OpenAPI spec that Stoat publishes via `stoatchat/javascript-client-api`. - ---- - -## Authentication Strategy - -Stoat officially recommends that third-party clients **do not handle usernames and passwords**. Users obtain their session token from the web client and paste it into VimStoat. This is the same approach used by other third-party Revolt/Stoat clients. - -### How It Works - -1. User logs into Stoat web client -2. Opens browser DevTools → Application → Local Storage -3. Copies their session token -4. Pastes it into VimStoat on first launch -5. VimStoat stores it securely in the OS keyring via `keyring-lib` -6. On subsequent launches, token is loaded from keyring automatically - -### Token Validation - -Currently we accept any string as a token. We need to validate it by calling `GET /users/@me` with the token as `X-Session-Token`. If it returns a `User` object, the token is valid. If it returns 401, we prompt again. - -### Auth Headers - -| Client Type | Header | Our Case | -| ------------ | ----------------- | ----------- | -| Bot | `X-Bot-Token` | ❌ Not us | -| User session | `X-Session-Token` | ✅ This one | - -### Instance Configuration - -The base URL should be configurable to support self-hosted instances: - -- Default: `https://api.stoat.chat` -- Configurable via: `~/.config/vimstoat/config.toml` or `--instance` CLI flag - ---- - -## Project Structure - -The current codebase is 3 files. Here's where we need to go: - -``` -vimstoat/ -├── Cargo.toml -├── README.md -├── ROADMAP.md ← you are here -├── config.example.toml ← example user config -│ -└── src/ - ├── main.rs ← entry point: init terminal, run event loop, restore - ├── app.rs ← root App struct, owns all state, dispatches actions - ├── action.rs ← Action enum: every possible state mutation - ├── input.rs ← (Mode, KeyEvent) → Vec, pending-key buffer - ├── tui.rs ← terminal init/restore, panic hooks, alternate screen - ├── event.rs ← async event source: keys, ticks, API events via mpsc - ├── error.rs ← AppError enum (thiserror), Result alias - ├── config.rs ← instance URL, theme, keybind overrides - │ - ├── api/ - │ ├── mod.rs - │ ├── client.rs ← StoatApi struct: thin reqwest wrapper - │ ├── auth.rs ← token validation, keyring read/write - │ └── ws.rs ← WebSocket connection, event stream - │ - ├── state/ - │ ├── mod.rs - │ ├── chat.rs ← server/channel/message/user caches - │ └── ui.rs ← scroll offsets, selections, panel focus - │ - └── components/ - ├── mod.rs ← Component trait definition - ├── login.rs ← token input screen - ├── server_list.rs ← left sidebar - ├── channel_list.rs ← channel panel - ├── message_view.rs ← main message area (scrollable) - ├── message_input.rs← compose bar (Insert mode target) - ├── command_line.rs ← ":" command bar - └── status_bar.rs ← mode indicator + context info -``` - -### Core Design Pattern - -**Component + Action Dispatch** (ratatui community best practice): - -``` - Crossterm KeyEvent - │ - ▼ - ┌─────────────┐ - │ input.rs │ Pure function: (Mode, Key) → Actions - └──────┬──────┘ - │ Vec - ▼ - ┌─────────────┐ - │ app.rs │ match action { ... } → mutate state - └──────┬──────┘ - │ &AppState - ▼ - ┌─────────────┐ - │ components/ │ Pure rendering: (&State, Rect) → Frame - └─────────────┘ -``` - -State flows down. Events flow up as Actions. No component directly mutates state. This keeps everything testable and predictable. - -### Key Dependencies - -| Crate | Purpose | Replaces | -| ------------------------ | ------------------------ | ---------------------- | -| `ratatui` | TUI framework | (keep) | -| `crossterm` | terminal backend & input | (implicit via ratatui) | -| `tokio` | async runtime | (keep) | -| `reqwest` (json feature) | HTTP client | `stoat-rs` | -| `revolt-models` | API type definitions | `stoat-rs` re-exports | -| `tokio-tungstenite` | WebSocket client | — | -| `keyring-lib` | secure token storage | (keep) | -| `thiserror` | error types | `Box` | -| `serde` / `serde_json` | JSON serialization | — | -| `directories` | XDG config paths | — | -| `toml` | config file parsing | — | - ---- - -## Vim Modal System - -### Modes - -```rust -enum Mode { - Normal, // Default. Navigate, scroll, select. - Insert, // Typing a message. Input goes to compose bar. - Command, // ":" prefix. Commands like :quit, :join, :help. - Visual, // Future. Select text or messages. -} -``` - -The mode is always visible in the status bar: `-- NORMAL --`, `-- INSERT --`, etc. - -### Mode Transitions - -``` - ┌──────────┐ - ┌────i────│ NORMAL │────:────┐ - │ a │ (default)│ │ - │ o └────┬─────┘ │ - ▼ │ ▼ - ┌──────────┐ v (future) ┌───────────┐ - │ INSERT │ │ │ COMMAND │ - │ │ ▼ │ │ - └────┬─────┘ ┌──────────┐ └─────┬─────┘ - │ │ VISUAL │ │ - │ └────┬─────┘ │ - │ │ │ - └──── Esc ─────┴──── Esc ─────┘ - (back to Normal) -``` - -### Pending Key Buffer - -Vim has multi-key commands: `gg`, `dd`, `yy`, `Ctrl+w h`. We need a small state machine: - -```rust -struct PendingKey { - keys: Vec, - timeout: Duration, // reset if no follow-up within ~500ms -} -``` - -When `g` is pressed in Normal mode, we buffer it and wait. If `g` comes again within the timeout → `Action::JumpToTop`. If timeout expires or a different key comes → flush buffer as individual keys. - ---- - -## Keybinding Reference - -### Normal Mode — Navigation & Actions - -**Movement:** -| Key | Action | -|-----|--------| -| `j` / `↓` | Select next item (channel, message) | -| `k` / `↑` | Select previous item | -| `h` / `←` | Focus panel left (servers ← channels ← messages) | -| `l` / `→` | Focus panel right | -| `gg` | Jump to top of list | -| `G` | Jump to bottom (most recent) | -| `Ctrl+d` | Half-page scroll down | -| `Ctrl+u` | Half-page scroll up | -| `Ctrl+f` | Full page down | -| `Ctrl+b` | Full page up | -| `H` | Top of visible area | -| `M` | Middle of visible area | -| `L` | Bottom of visible area | - -**Mode switching:** -| Key | Action | -|-----|--------| -| `i` | Enter Insert mode (focus input bar) | -| `I` | Enter Insert mode, cursor at start | -| `a` | Enter Insert mode, cursor after current pos | -| `A` | Enter Insert mode, cursor at end | -| `o` | Enter Insert mode, start new message | -| `:` | Enter Command mode | -| `/` | Search (enters Command mode with `/` prefix) | -| `v` | Enter Visual mode (future) | - -**Actions on messages:** -| Key | Action | -|-----|--------| -| `Enter` | Open/select (enter channel, expand thread) | -| `r` | Reply to selected message | -| `e` | Edit message (if yours) | -| `dd` | Delete message (if yours, with confirmation) | -| `yy` | Copy message content to clipboard | -| `n` | Next search result | -| `N` | Previous search result | - -**Window management:** -| Key | Action | -|-----|--------| -| `Tab` | Cycle focus to next panel | -| `Shift+Tab` | Cycle focus to previous panel | -| `Ctrl+w h` | Focus panel left | -| `Ctrl+w j` | Focus panel below | -| `Ctrl+w k` | Focus panel above | -| `Ctrl+w l` | Focus panel right | - -**General:** -| Key | Action | -|-----|--------| -| `q` | Quit | -| `Ctrl+c` | Quit | -| `Ctrl+l` | Force redraw | - -### Insert Mode — Composing Messages - -| Key | Action | -| ----------------- | ------------------------------ | -| `Esc` / `Ctrl+[` | Back to Normal mode | -| `Enter` | Send message | -| `Ctrl+c` | Cancel, back to Normal | -| `Backspace` | Delete character before cursor | -| `Ctrl+w` | Delete word before cursor | -| `Ctrl+u` | Clear entire input line | -| `Home` / `Ctrl+a` | Cursor to start of input | -| `End` / `Ctrl+e` | Cursor to end of input | -| `Ctrl+←` | Move cursor one word left | -| `Ctrl+→` | Move cursor one word right | -| All other chars | Insert into message | - -### Command Mode — `:` Commands - -| Command | Action | -| ----------------------- | ------------------------------------- | -| `:q` / `:quit` | Quit the application | -| `:w` | Not applicable (but could save draft) | -| `:wq` | Send current draft and quit | -| `:join ` | Switch to channel | -| `:server ` | Switch to server | -| `:reply` | Reply to selected message | -| `:edit` | Edit selected message | -| `:delete` | Delete selected message | -| `:search ` | Search messages in current channel | -| `:set