use std::{collections::HashMap, ops::Deref, sync::Arc, time::{Duration, Instant}}; use atproto_client::{HttpRecordResolver, client::{AppPasswordAuth, Auth::self}, com::atproto::server::{AppPasswordSession, refresh_session}, errors::ClientError}; use atproto_identity::{config::default_env, plc::did::DID_PLC_PREFIX, resolve::{HickoryDnsResolver, InnerIdentityResolver, SharedIdentityResolver}, traits::DnsResolver}; use atproto_lexicon::{resolve::pds_endpoint, validation::syntax::validate_handle}; use clap::{Parser, crate_name, crate_version}; use crossterm::event::{self, KeyCode::{self}, KeyEventKind}; use rand::{RngExt, SeedableRng}; use ratatui::{DefaultTerminal, Frame, layout::{Constraint, Layout, Position}, style::{Color, Stylize}, symbols, text::{Line, Text}, widgets::{Block, Paragraph}}; #[derive(Parser, Debug)] #[command( name = "microchat", version, about = "A simple text chat CLI and Proof of Concept for AT Micro." )] struct Args { handle: String, app_password: String, } const MAX_INPUT_LINES: usize = 4; #[tokio::main] async fn main() -> anyhow::Result<()> { // Log in let args = Args::parse(); let plc_hostname = default_env("PLC_HOSTNAME", "plc.directory"); let client = atproto_client::reqwest::Client::builder() .user_agent(format!("{}/{} (https://tangled.org/sylv.gay/microchat)", crate_name!(), crate_version!())) .build()?; let dns_resolver = Arc::new(HickoryDnsResolver::create_resolver(&[])); // Start the TUI let terminal = ratatui::init(); App::new(client, dns_resolver, plc_hostname, args.handle, args.app_password).await?.run(terminal).await?; ratatui::restore(); Ok(()) } #[derive(Debug, Clone)] struct UserInfo { pub display_name: Option, pub handle: String, pub did: String, pub color: u8, } #[derive(Debug)] struct Message { pub user_info: Arc, pub content: String, } #[derive(Debug)] struct Participant { pub user_info: Arc, } #[derive(Debug)] struct Room { pub me: Participant, pub participants: Vec, } #[derive(Debug, Default, PartialEq)] enum InputMode { Edit, #[default] Send, } #[derive(Debug, Default)] struct ClientState { messages: Vec, input: String, input_mode: InputMode, last_input: Option, character_position: usize, room: Option, } fn ansi_color_from_did_plc(did: &str) -> anyhow::Result { if !did.starts_with(DID_PLC_PREFIX) { return Ok(8); } let did_hash = atproto_identity::plc::encoding::base32_decode(&did.trim_start_matches(DID_PLC_PREFIX))?; let mut seed = 0u64; for i in 0..8 { let part = *did_hash.get(did_hash.len() - 1 - i).expect("DID:PLC should be at least 16 bytes") as u64; seed |= part << 8 * i; } let mut rng = rand::rngs::Xoshiro128PlusPlus::seed_from_u64(seed); return Ok(rng.random_range(17..=230)); } struct App { cs: ClientState, http_client: atproto_client::reqwest::Client, my_info: Arc, identity_resolver: SharedIdentityResolver, record_resolver: HttpRecordResolver, pds_endpoint: String, auth: Auth, session: AppPasswordSession, } impl App { pub async fn new(http_client: atproto_client::reqwest::Client, dns_resolver: Arc, plc_hostname: String, handle: String, app_password: String) -> anyhow::Result { validate_handle(&handle)?; let identity_resolver = SharedIdentityResolver(Arc::new(InnerIdentityResolver { dns_resolver, http_client: http_client.clone(), plc_hostname, })); let did_document = identity_resolver.resolve(&handle).await?; let pds = pds_endpoint(&did_document)?; let session = atproto_client::com::atproto::server::create_session( &http_client, &pds, &handle, &app_password, None ).await?; let did = did_document.id.clone(); Ok(Self { cs: Default::default(), http_client: http_client.clone(), my_info: Arc::new(UserInfo { display_name: None, handle: handle.clone(), color: ansi_color_from_did_plc(&did)?, did }), identity_resolver: identity_resolver.clone(), record_resolver: HttpRecordResolver::new(http_client, identity_resolver.0), pds_endpoint: pds, auth: Auth::AppPassword(AppPasswordAuth { access_token: session.access_jwt.clone() }), session, }) } async fn auto_refresh>, O: Fn() -> F>(&mut self, op: O) -> anyhow::Result { match op().await { Err(cause) => match cause.downcast_ref::() { Some(e) => if let ClientError::HttpRequestFailed { url: _, error } = e { if error.is_body() && error.to_string().contains("ExpiredToken") { let response = refresh_session( &self.http_client, &self.pds_endpoint, &self.session.refresh_jwt ).await?; self.session.access_jwt = response.access_jwt; self.session.refresh_jwt = response.refresh_jwt; op().await } else { Err(cause) } } else { Err(cause) }, None => Err(cause), }, Ok(result) => Ok(result), } } pub async fn run(mut self, mut terminal: DefaultTerminal) -> anyhow::Result<()> { loop { terminal.draw(|frame| self.render(frame))?; // debug messages // self.messages.clear(); // let index_to_split_reference: HashMap = self.input // .split('\n') // .fold((HashMap::new(), Vec::new()), |(mut result, mut acc): (HashMap, Vec), line| { // if let Some(last_index) = acc.last() { // let len_including_newline = line.len() + 1; // let v = acc.len(); // let k = last_index + len_including_newline; // let last_index = *last_index; // acc.push(k); // for i in last_index..=k { // result.insert(i, v); // } // } else { // acc.push(line.len()); // result.insert(line.len(), 0); // for i in 0..=line.len() { // result.insert(i, 0); // } // } // (result, acc) // }).0; // let split = *index_to_split_reference.get(&(self.character_position)).unwrap_or(&0) as u16; // let acc_len: usize = self.input // .split('\n') // .enumerate() // .filter(|(i, _)| *i < split as usize) // .map(|(_, v)| v) // .map(&str::len) // .sum(); // // let acc_len = split_to_index_reference.last().copied().unwrap_or(0); // self.debug_message(format!("{:?}", index_to_split_reference)); // self.debug_message(format!("chr_pos: {}", self.character_position)); // self.debug_message(format!("acc_len: {}", acc_len)); // self.debug_message(format!("chr_pos - acc_len: {}", self.character_position.wrapping_sub(acc_len))); // self.debug_message(format!("split: {}", split)); if event::poll(Duration::ZERO)? { let event = event::read()?; if let Some(key) = event.as_key_event() { if key.kind == KeyEventKind::Press { let last_input = self.cs.last_input; self.cs.last_input = Some(Instant::now()); match key.code { KeyCode::Char(c) => match self.cs.input_mode { InputMode::Edit => self.push_char(c), InputMode::Send => if c == 't' || c == '/' { if c == '/' { self.push_char('/'); } self.cs.input_mode = InputMode::Edit; }, }, KeyCode::Backspace => match self.cs.input_mode { InputMode::Edit => { if self.cs.character_position > 0 { self.cs.input.remove(self.char_to_byte_index(self.cs.character_position - 1)); self.cs.character_position -= 1; } }, _ => {} }, KeyCode::Enter => match self.cs.input_mode { InputMode::Edit => { if self.count_input_lines() < MAX_INPUT_LINES { self.push_char('\n'); } }, InputMode::Send => self.send_message(), }, KeyCode::Esc => match self.cs.input_mode { InputMode::Edit => self.cs.input_mode = InputMode::Send, InputMode::Send => if last_input.is_none() || last_input.unwrap().elapsed() > Duration::from_millis(250) { // Don't exit if the user just pressed something. break }, }, KeyCode::Left => self.move_left(), KeyCode::Right => self.move_right(), _ => {} } } } } } self.disconnect().await?; Ok(()) } fn push_char(&mut self, char: char) { self.cs.input.insert(self.char_to_byte_index(self.cs.character_position), char); self.cs.character_position += 1; } fn char_to_byte_index(&self, char_index: usize) -> usize { self.cs.input .char_indices() .map(|(i, _)| i) .nth(char_index) .unwrap_or(self.cs.input.len()) } fn count_input_lines(&self) -> usize { self.cs.input.matches('\n').count() + 1 } fn send_message(&mut self) { if self.cs.input.split_whitespace().count() == 0 { return } let content = self.cs.input.trim(); self.display_message(Message { user_info: self.my_info.clone(), content: content.to_owned() }); self.cs.input.clear(); self.cs.character_position = 0; } fn display_message(&mut self, message: Message) { self.cs.messages.push(message); } fn debug_message(&mut self, content: String) { self.display_message(Message { user_info: Arc::new(UserInfo { display_name: Some("debug".to_owned()), handle: "handle.invalid".to_owned(), did: "did:web:localhost".to_owned(), color: 8 }), content }); } fn move_left(&mut self) { if self.cs.character_position > 0 { self.cs.character_position -= 1; } } fn move_right(&mut self) { if self.cs.character_position < self.cs.input.len() { self.cs.character_position += 1; } } fn render(&self, frame: &mut Frame) { let layout = Layout::vertical([ Constraint::Length(1), Constraint::Fill(1), Constraint::Max(MAX_INPUT_LINES as u16 + 2), ]); let [title_area, messages_area, input_area] = frame.area().layout(&layout); frame.render_widget(Paragraph::new(Line::from("microchat".bold()).centered()), title_area); let scroll = std::cmp::max( 0, self.cs .messages .iter() .map(|msg| msg.content.split('\n').count()) .map(|x| x as i32) .sum::() - messages_area.height as i32 + 2 ) as u16; let messages: Text = self.cs.messages .iter() .map(|msg| { let text = Text::from(&*msg.content); Text::from(((msg.user_info.display_name.as_ref().map(|x|&**x).unwrap_or_else(||&*msg.user_info.handle))).fg(Color::Indexed(msg.user_info.color)) + ": ".fg(Color::Reset).into() + text.lines.first().cloned().unwrap_or_default().spans.first().cloned().unwrap_or_default()) + text.into_iter().enumerate().filter(|(i, _)| *i > 0).map(|(_, m)| m).collect::() }) .fold(Text::default(), |a, b| a + b); let messages = Paragraph::new(messages) .block(Block::bordered().title(Line::from("Messages".bold()))) .scroll((scroll, 0)); frame.render_widget(messages, messages_area); let raw_input_lines: Vec<&str> = self.cs.input .split('\n') .collect(); // FIXME: this is probably the laggiest piece of s%@& in this entire codebase let index_to_split_reference: HashMap = self.cs.input .split('\n') .fold((HashMap::new(), Vec::new()), |(mut result, mut acc): (HashMap, Vec), line| { if let Some(last_index) = acc.last() { let len_including_newline = line.len() + 1; let v = acc.len(); let k = last_index + len_including_newline; let last_index = *last_index; acc.push(k); for i in last_index..=k { result.insert(i, v); } } else { acc.push(line.len()); result.insert(line.len(), 0); for i in 0..=line.len() { result.insert(i, 0); } } (result, acc) }).0; let input_lines: Vec = if self.cs.input.is_empty() { vec![Line::from("Press T to begin typing a message. Press ESC when finished and Enter to send.").dim()] } else { raw_input_lines .iter() .map(Deref::deref) // deref double ref &&str .map(Line::from) .collect() }; let input = Paragraph::new(input_lines) .block(Block::bordered().border_set(match self.cs.input_mode { InputMode::Edit => symbols::border::DOUBLE, InputMode::Send => symbols::border::PLAIN, })); frame.render_widget(input, input_area); if !self.cs.input.is_empty() && self.cs.input_mode == InputMode::Edit { let mut split = *index_to_split_reference.get(&(self.cs.character_position)).unwrap_or(&0) as u16; if self.cs.input.chars().nth(self.cs.character_position).is_some() && self.cs.input.chars().nth(self.cs.character_position).unwrap() == '\n' { split -= 1; } let len_x = raw_input_lines.get(split as usize).expect("s2i map should be calculated").len() as u16; let acc_len: usize = self.cs.input .split('\n') .enumerate() .filter(|(i, _)| *i < split as usize) .map(|(_, v)| v) .map(&str::len) .sum(); // let acc_len = split_to_index_reference.last().copied().unwrap_or(0); let mut normalized_pos = self.cs.character_position as i32 - acc_len as i32 - split as i32; if normalized_pos < 0 { normalized_pos = len_x as i32; } let pos_x = if split > 0 { std::cmp::min(64, normalized_pos as u16) } else { self.cs.character_position as u16 }; frame.set_cursor_position(Position::new( input_area.x + pos_x + 1, input_area.y + split + 1 )); } } async fn disconnect(&mut self) -> anyhow::Result<()> { let session_deletion = atproto_client::com::atproto::server::delete_session( &self.http_client, &self.pds_endpoint, &self.session.refresh_jwt ); // Finish everything session_deletion.await?; Ok(()) } }