feat: now usable! still needs auth tho
This commit is contained in:
parent
a6e7521427
commit
52ed43745d
5 changed files with 214 additions and 81 deletions
|
@ -23,6 +23,7 @@ ratatui = { version = "0.26", features = ["all-widgets", "crossterm"] }
|
||||||
crossterm = { version = "0.27", features = ["event-stream"] }
|
crossterm = { version = "0.27", features = ["event-stream"] }
|
||||||
async-trait = "0.1.80"
|
async-trait = "0.1.80"
|
||||||
futures = "0.3.30"
|
futures = "0.3.30"
|
||||||
|
thiserror = "1.0.61"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["native-tls"]
|
default = ["native-tls"]
|
||||||
|
|
115
src/app.rs
115
src/app.rs
|
@ -1,13 +1,51 @@
|
||||||
use crossterm::event::{Event, KeyCode, KeyModifiers};
|
use crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||||
use ratatui::{prelude::*, widgets::{Block, Borders, Paragraph}};
|
use ratatui::{prelude::*, widgets::{Block, Borders, Paragraph, Wrap}};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use crate::chat::Timeline;
|
|
||||||
|
use crate::{chat::{Chat, ChatEvent}, proto::ws::Action};
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct History {
|
||||||
|
lines: Vec<ChatEvent>,
|
||||||
|
offset: u16,
|
||||||
|
count: u16,
|
||||||
|
height: u16,
|
||||||
|
width: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl History {
|
||||||
|
fn up(&mut self, x: u16) {
|
||||||
|
self.offset = self.offset.saturating_sub(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn down(&mut self, x: u16) {
|
||||||
|
if self.count < self.height { return };
|
||||||
|
let delta = self.count - self.height;
|
||||||
|
if self.offset >= delta { return }
|
||||||
|
self.offset += std::cmp::min(x, delta - self.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recalculate(&mut self) {
|
||||||
|
self.count = 0;
|
||||||
|
for line in &self.lines {
|
||||||
|
let len = (format!("{line}").len() as u16 / self.width) + 1;
|
||||||
|
self.count += len;
|
||||||
|
}
|
||||||
|
self.down(self.count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
pub async fn run<T: ratatui::backend::Backend>(term: &mut Terminal<T>, timeline: Timeline) -> std::io::Result<()> {
|
pub async fn run<T: ratatui::backend::Backend>(term: &mut Terminal<T>, args: crate::Args) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut chat = Chat::register(&args.server).await?;
|
||||||
|
|
||||||
let mut stream = crossterm::event::EventStream::new();
|
let mut stream = crossterm::event::EventStream::new();
|
||||||
let mut last_draw = tokio::time::Instant::now() - std::time::Duration::from_secs(1);
|
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
|
let mut history = History {
|
||||||
|
height: term.size()?.height - 3,
|
||||||
|
width: term.size()?.width,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
|
@ -15,56 +53,93 @@ pub async fn run<T: ratatui::backend::Backend>(term: &mut Terminal<T>, timeline:
|
||||||
|
|
||||||
res = stream.next() => match res {
|
res = stream.next() => match res {
|
||||||
None => break Ok(()), // EOF
|
None => break Ok(()), // EOF
|
||||||
Some(Err(e)) => break Err(e),
|
Some(Err(e)) => break Err(Box::new(e)),
|
||||||
Some(Ok(ev)) => match ev {
|
Some(Ok(ev)) => match ev {
|
||||||
Event::FocusGained | Event::FocusLost | Event::Resize(_, _) => {},
|
Event::FocusGained | Event::FocusLost => {},
|
||||||
Event::Mouse(_) => {}, // TODO handle mouse? eventually :tm:
|
Event::Mouse(_) => {}, // TODO handle mouse? eventually :tm:
|
||||||
Event::Paste(p) => input.push_str(&p), // TODO paste at cursor
|
Event::Paste(p) => input.push_str(&p), // TODO paste at cursor
|
||||||
|
Event::Resize(c, r) => {
|
||||||
|
history.height = r - 3;
|
||||||
|
history.width = c;
|
||||||
|
history.recalculate();
|
||||||
|
},
|
||||||
Event::Key(k) => {
|
Event::Key(k) => {
|
||||||
if k.modifiers == KeyModifiers::CONTROL && matches!(k.code, KeyCode::Char('q' | 'c')) {
|
if k.modifiers == KeyModifiers::CONTROL && matches!(k.code, KeyCode::Char('q' | 'c')) {
|
||||||
break Ok(());
|
break Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
match k.code {
|
match k.code {
|
||||||
KeyCode::Enter => {},
|
KeyCode::Backspace => { input.pop(); },
|
||||||
KeyCode::Backspace => {},
|
|
||||||
KeyCode::Char(c) => input.push(c),
|
KeyCode::Char(c) => input.push(c),
|
||||||
|
KeyCode::Up => history.up(1),
|
||||||
|
KeyCode::PageUp => history.up(10),
|
||||||
|
KeyCode::Down => history.down(1),
|
||||||
|
KeyCode::PageDown => history.down(10),
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if let Err(_e) = chat.send(Action::new(input.clone())).await {
|
||||||
|
// TODO log it;
|
||||||
|
} else {
|
||||||
|
input = String::new();
|
||||||
|
}
|
||||||
|
},
|
||||||
_ => {},
|
_ => {},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// redraw anyway every second
|
res = chat.recv() => match res {
|
||||||
_ = tokio::time::sleep_until(last_draw + std::time::Duration::from_secs(1)) => {},
|
None => break Ok(()), // stream closed, TODO maybe say it and dont exit?
|
||||||
|
Some(event) => {
|
||||||
|
let lines = (format!("{event}").len() as u16 / history.width) + 1;
|
||||||
|
history.count += lines;
|
||||||
|
history.down(lines);
|
||||||
|
history.lines.push(event);
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let tl_input = input.clone();
|
let tl_input = input.clone();
|
||||||
let mut tl_text = timeline.read().await.join("\n");
|
let tl_input_len = ((tl_input.len() as u16 + 3) / history.width) + 1;
|
||||||
|
let tl_header = args.server.clone();
|
||||||
|
// TODO only render bottom lines
|
||||||
|
let tl_text = history
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.map(|x| format!("{x}"))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
term.draw(|frame| {
|
term.draw(|frame| {
|
||||||
|
|
||||||
tl_text.push_str(&format!("\n{}", chrono::Utc::now()));
|
|
||||||
|
|
||||||
let layout = Layout::default()
|
let layout = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints(vec![
|
.constraints(vec![
|
||||||
Constraint::Percentage(100),
|
Constraint::Percentage(100),
|
||||||
Constraint::Min(3),
|
Constraint::Min(1 + tl_input_len),
|
||||||
])
|
])
|
||||||
.split(frame.size());
|
.split(frame.size());
|
||||||
|
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(tl_text)
|
Paragraph::new(tl_text)
|
||||||
.block(Block::new().borders(Borders::ALL)),
|
.wrap(Wrap { trim: false })
|
||||||
|
.scroll((history.offset, 0))
|
||||||
|
.block(
|
||||||
|
Block::new()
|
||||||
|
.borders(Borders::TOP)
|
||||||
|
.title(tl_header)
|
||||||
|
.title_alignment(Alignment::Right)
|
||||||
|
),
|
||||||
layout[0]
|
layout[0]
|
||||||
);
|
);
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(tl_input)
|
Paragraph::new(format!("> {tl_input}_"))
|
||||||
.block(Block::new().borders(Borders::ALL)),
|
.wrap(Wrap { trim: false })
|
||||||
|
.block(
|
||||||
|
Block::new()
|
||||||
|
.borders(Borders::TOP)
|
||||||
|
),
|
||||||
layout[1]
|
layout[1]
|
||||||
);
|
);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
last_draw = tokio::time::Instant::now();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
138
src/chat.rs
138
src/chat.rs
|
@ -1,73 +1,129 @@
|
||||||
use std::sync::Arc;
|
use core::fmt;
|
||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
// TODO im so angry tokio-tungstenite makes me import this!
|
// TODO im so angry tokio-tungstenite makes me import this!
|
||||||
use futures_util::StreamExt;
|
use futures_util::{StreamExt, SinkExt};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::mpsc;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
use crate::proto::{http::RegisterResponse, ws::{ChatEvent, ChatEventInner}};
|
use crate::proto::{http::RegisterResponse, ws::{Event, EventInner}};
|
||||||
|
|
||||||
pub type Timeline = Arc<RwLock<Vec<String>>>;
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ChatError {
|
||||||
|
#[error("failed registering to chat: {0:?}")]
|
||||||
|
Register(#[from] reqwest::Error),
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[error("connection error with chat: {0:?}")]
|
||||||
pub trait PushableTimeline {
|
Connect(#[from] tokio_tungstenite::tungstenite::Error),
|
||||||
async fn push(&self, msg: String);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[derive(Debug)]
|
||||||
impl PushableTimeline for Timeline {
|
pub struct ChatEvent(chrono::DateTime<chrono::Utc>, ChatLine);
|
||||||
async fn push(&self, msg: String) {
|
|
||||||
self.write().await.push(msg)
|
#[derive(Debug)]
|
||||||
|
pub enum ChatLine {
|
||||||
|
Message {
|
||||||
|
user: String,
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
Error(String),
|
||||||
|
Join(String),
|
||||||
|
Leave(String),
|
||||||
|
Connect(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for ChatEvent {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let time = self.0.format("%H:%M:%S");
|
||||||
|
match &self.1 {
|
||||||
|
ChatLine::Error(err) => write!(f, "{time} /!\\ {err}"),
|
||||||
|
ChatLine::Join(user) => write!(f, "{time} [{user} joined]"),
|
||||||
|
ChatLine::Leave(user) => write!(f, "{time} [{user} left]"),
|
||||||
|
ChatLine::Connect(user) => write!(f, "{time} [{user} connected]"),
|
||||||
|
ChatLine::Message { user, body } => {
|
||||||
|
let pre = format!("{time} <{user}> ");
|
||||||
|
let pretty_body = dissolve::strip_html_tags(body).join("\n");
|
||||||
|
write!(f, "{pre}{pretty_body}")
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn worker(server: String, timeline: Timeline) {
|
pub struct Chat {
|
||||||
timeline.push("[ ] connecting...".to_string()).await;
|
sink: ChatSink,
|
||||||
|
rx: mpsc::UnboundedReceiver<ChatEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Chat {
|
||||||
|
pub async fn register(server: &str) -> Result<Chat, ChatError> {
|
||||||
let registration : RegisterResponse = reqwest::Client::new()
|
let registration : RegisterResponse = reqwest::Client::new()
|
||||||
.post(format!("https://{server}/api/chat/register"))
|
.post(format!("https://{server}/api/chat/register"))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await?
|
||||||
.expect("failed sending registration request")
|
|
||||||
.json()
|
.json()
|
||||||
.await
|
.await?;
|
||||||
.expect("failed parsing registration response");
|
|
||||||
|
|
||||||
timeline.push(format!("registered to chat: {registration:?}")).await;
|
|
||||||
|
|
||||||
// TODO use url crate?
|
|
||||||
let ws_url = format!("wss://{server}/ws?accessToken={}", registration.access_token);
|
let ws_url = format!("wss://{server}/ws?accessToken={}", registration.access_token);
|
||||||
|
|
||||||
timeline.push(format!("connecting to {ws_url}")).await;
|
let (ws, _response) = tokio_tungstenite::connect_async(ws_url).await?;
|
||||||
|
|
||||||
match tokio_tungstenite::connect_async(ws_url).await {
|
let (sink, stream) = ws.split();
|
||||||
Err(e) => timeline.push(format!("[!] failed connecting: {e}")).await,
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
Ok((mut stream, _)) => while let Some(next) = stream.next().await {
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = Chat::worker(stream, tx).await {
|
||||||
|
eprintln!("chat channel closed while sending a message: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Chat { sink, rx })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send(&mut self, message: crate::proto::ws::Action) -> Result<(), tokio_tungstenite::tungstenite::Error> {
|
||||||
|
self.sink.send(
|
||||||
|
Message::Text(
|
||||||
|
serde_json::to_string(&message).expect("error serializing ChatAction")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline] // because mpsc::recv is cancelable, but this wrapper may not be
|
||||||
|
pub async fn recv(&mut self) -> Option<ChatEvent> {
|
||||||
|
self.rx.recv().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn worker(mut stream: ChatStream, chan: mpsc::UnboundedSender<ChatEvent>) -> Result<(), mpsc::error::SendError<ChatEvent>> {
|
||||||
|
while let Some(next) = stream.next().await {
|
||||||
match next {
|
match next {
|
||||||
Err(e) => timeline.push(format!("[!] error receiving chat message: {e}")).await,
|
Err(e) => chan.send(ChatEvent(chrono::Utc::now(), ChatLine::Error(format!("failed receiving message: {e}"))))?,
|
||||||
Ok(msg) => match msg {
|
Ok(msg) => match msg {
|
||||||
Message::Binary(_) => timeline.push("[!] received unexpected binary payload".to_string()).await,
|
Message::Close(_) => break,
|
||||||
Message::Frame(_) => timeline.push("[!] received unexpected raw frame".to_string()).await,
|
Message::Binary(_) => chan.send(ChatEvent(chrono::Utc::now(), ChatLine::Error("unexpected binary payload".to_string())))?,
|
||||||
|
Message::Frame(_) => chan.send(ChatEvent(chrono::Utc::now(), ChatLine::Error("unexpected raw frame".to_string())))?,
|
||||||
Message::Ping(_) | tokio_tungstenite::tungstenite::Message::Pong(_) => {}, // ignore
|
Message::Ping(_) | tokio_tungstenite::tungstenite::Message::Pong(_) => {}, // ignore
|
||||||
Message::Close(_) => timeline.push("[x] stream is closing".to_string()).await,
|
|
||||||
Message::Text(payload) => for line in payload.lines() {
|
Message::Text(payload) => for line in payload.lines() {
|
||||||
match serde_json::from_str::<ChatEvent>(line) {
|
match serde_json::from_str::<Event>(line) {
|
||||||
Err(e) => timeline.push(format!("[!] failed deserializing chat message: {e} -- {payload}")).await,
|
Err(e) => chan.send(ChatEvent(chrono::Utc::now(), ChatLine::Error(format!("failed parsing message: {e}"))))?,
|
||||||
Ok(event) => match event.inner {
|
Ok(event) => match event.inner {
|
||||||
ChatEventInner::Chat { body, visible: _ } =>
|
EventInner::Chat { body, visible: _ } =>
|
||||||
timeline.push(format!("{} | [{}]> {}", event.timestamp, event.user.display_name, dissolve::strip_html_tags(&body).join(" "))).await,
|
chan.send(ChatEvent(event.timestamp, ChatLine::Message { user: event.user.display_name, body }))?,
|
||||||
ChatEventInner::ConnectedUserInfo =>
|
EventInner::ConnectedUserInfo =>
|
||||||
timeline.push(format!("{} | {} connected", event.timestamp, event.user.display_name)).await,
|
chan.send(ChatEvent(event.timestamp, ChatLine::Connect(event.user.display_name)))?,
|
||||||
ChatEventInner::UserJoined =>
|
EventInner::UserJoined =>
|
||||||
timeline.push(format!("{} | {} joined", event.timestamp, event.user.display_name)).await,
|
chan.send(ChatEvent(event.timestamp, ChatLine::Join(event.user.display_name)))?,
|
||||||
ChatEventInner::UserParted =>
|
EventInner::UserParted =>
|
||||||
timeline.push(format!("{} | {} left", event.timestamp, event.user.display_name)).await,
|
chan.send(ChatEvent(event.timestamp, ChatLine::Leave(event.user.display_name)))?,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
timeline.write().await.push("[x] stream closed".to_string());
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChatStream = futures::stream::SplitStream<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>>;
|
||||||
|
type ChatSink = futures::stream::SplitSink<tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>, Message>;
|
||||||
|
|
|
@ -15,13 +15,6 @@ struct Args {
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
let timeline = chat::Timeline::default();
|
|
||||||
let _tl = timeline.clone();
|
|
||||||
let _server = args.server.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
crate::chat::worker(_server, _tl).await
|
|
||||||
});
|
|
||||||
|
|
||||||
// setup terminal
|
// setup terminal
|
||||||
crossterm::terminal::enable_raw_mode()?;
|
crossterm::terminal::enable_raw_mode()?;
|
||||||
let mut stdout = std::io::stdout();
|
let mut stdout = std::io::stdout();
|
||||||
|
@ -33,7 +26,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut terminal = ratatui::Terminal::new(backend)?;
|
let mut terminal = ratatui::Terminal::new(backend)?;
|
||||||
terminal.hide_cursor()?;
|
terminal.hide_cursor()?;
|
||||||
|
|
||||||
let res = app::run(&mut terminal, timeline).await;
|
let res = app::run(&mut terminal, args).await;
|
||||||
|
|
||||||
// restore terminal
|
// restore terminal
|
||||||
crossterm::terminal::disable_raw_mode()?;
|
crossterm::terminal::disable_raw_mode()?;
|
||||||
|
|
20
src/proto.rs
20
src/proto.rs
|
@ -16,24 +16,32 @@ pub mod http {
|
||||||
pub mod ws {
|
pub mod ws {
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub struct ChatAction {
|
pub struct Action {
|
||||||
pub r#type: String, // CHAT, ???
|
pub r#type: String, // CHAT, ???
|
||||||
pub body: String,
|
pub body: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Action {
|
||||||
|
pub fn new(body: String) -> Self {
|
||||||
|
Action {
|
||||||
|
body, r#type: "CHAT".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
pub struct ChatEvent {
|
pub struct Event {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
pub user: ChatUser,
|
pub user: User,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub inner: ChatEventInner,
|
pub inner: EventInner,
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO can i avoid repeating id,timestamp,user in each msg type??
|
// TODO can i avoid repeating id,timestamp,user in each msg type??
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum ChatEventInner {
|
pub enum EventInner {
|
||||||
#[serde(rename = "CHAT")]
|
#[serde(rename = "CHAT")]
|
||||||
Chat {
|
Chat {
|
||||||
body: String,
|
body: String,
|
||||||
|
@ -50,7 +58,7 @@ pub mod ws {
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
pub struct ChatUser {
|
pub struct User {
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
pub name_changed_at: chrono::DateTime<chrono::Utc>,
|
pub name_changed_at: chrono::DateTime<chrono::Utc>,
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
Loading…
Reference in a new issue