feat: proof of concept
threw this together this afternoon its not done but id better commit something already
This commit is contained in:
parent
36982f9d95
commit
a6e7521427
4 changed files with 257 additions and 0 deletions
70
src/app.rs
Normal file
70
src/app.rs
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
use crossterm::event::{Event, KeyCode, KeyModifiers};
|
||||||
|
use ratatui::{prelude::*, widgets::{Block, Borders, Paragraph}};
|
||||||
|
use futures::StreamExt;
|
||||||
|
use crate::chat::Timeline;
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn run<T: ratatui::backend::Backend>(term: &mut Terminal<T>, timeline: Timeline) -> std::io::Result<()> {
|
||||||
|
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();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
|
||||||
|
res = stream.next() => match res {
|
||||||
|
None => break Ok(()), // EOF
|
||||||
|
Some(Err(e)) => break Err(e),
|
||||||
|
Some(Ok(ev)) => match ev {
|
||||||
|
Event::FocusGained | Event::FocusLost | Event::Resize(_, _) => {},
|
||||||
|
Event::Mouse(_) => {}, // TODO handle mouse? eventually :tm:
|
||||||
|
Event::Paste(p) => input.push_str(&p), // TODO paste at cursor
|
||||||
|
Event::Key(k) => {
|
||||||
|
if k.modifiers == KeyModifiers::CONTROL && matches!(k.code, KeyCode::Char('q' | 'c')) {
|
||||||
|
break Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
match k.code {
|
||||||
|
KeyCode::Enter => {},
|
||||||
|
KeyCode::Backspace => {},
|
||||||
|
KeyCode::Char(c) => input.push(c),
|
||||||
|
_ => {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// redraw anyway every second
|
||||||
|
_ = tokio::time::sleep_until(last_draw + std::time::Duration::from_secs(1)) => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
let tl_input = input.clone();
|
||||||
|
let mut tl_text = timeline.read().await.join("\n");
|
||||||
|
term.draw(|frame| {
|
||||||
|
|
||||||
|
tl_text.push_str(&format!("\n{}", chrono::Utc::now()));
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints(vec![
|
||||||
|
Constraint::Percentage(100),
|
||||||
|
Constraint::Min(3),
|
||||||
|
])
|
||||||
|
.split(frame.size());
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(tl_text)
|
||||||
|
.block(Block::new().borders(Borders::ALL)),
|
||||||
|
layout[0]
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(tl_input)
|
||||||
|
.block(Block::new().borders(Borders::ALL)),
|
||||||
|
layout[1]
|
||||||
|
);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
last_draw = tokio::time::Instant::now();
|
||||||
|
}
|
||||||
|
}
|
73
src/chat.rs
Normal file
73
src/chat.rs
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// TODO im so angry tokio-tungstenite makes me import this!
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
|
use crate::proto::{http::RegisterResponse, ws::{ChatEvent, ChatEventInner}};
|
||||||
|
|
||||||
|
pub type Timeline = Arc<RwLock<Vec<String>>>;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait PushableTimeline {
|
||||||
|
async fn push(&self, msg: String);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl PushableTimeline for Timeline {
|
||||||
|
async fn push(&self, msg: String) {
|
||||||
|
self.write().await.push(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn worker(server: String, timeline: Timeline) {
|
||||||
|
timeline.push("[ ] connecting...".to_string()).await;
|
||||||
|
let registration : RegisterResponse = reqwest::Client::new()
|
||||||
|
.post(format!("https://{server}/api/chat/register"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed sending registration request")
|
||||||
|
.json()
|
||||||
|
.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);
|
||||||
|
|
||||||
|
timeline.push(format!("connecting to {ws_url}")).await;
|
||||||
|
|
||||||
|
match tokio_tungstenite::connect_async(ws_url).await {
|
||||||
|
Err(e) => timeline.push(format!("[!] failed connecting: {e}")).await,
|
||||||
|
Ok((mut stream, _)) => while let Some(next) = stream.next().await {
|
||||||
|
match next {
|
||||||
|
Err(e) => timeline.push(format!("[!] error receiving chat message: {e}")).await,
|
||||||
|
Ok(msg) => match msg {
|
||||||
|
Message::Binary(_) => timeline.push("[!] received unexpected binary payload".to_string()).await,
|
||||||
|
Message::Frame(_) => timeline.push("[!] received unexpected raw frame".to_string()).await,
|
||||||
|
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() {
|
||||||
|
match serde_json::from_str::<ChatEvent>(line) {
|
||||||
|
Err(e) => timeline.push(format!("[!] failed deserializing chat message: {e} -- {payload}")).await,
|
||||||
|
Ok(event) => match event.inner {
|
||||||
|
ChatEventInner::Chat { body, visible: _ } =>
|
||||||
|
timeline.push(format!("{} | [{}]> {}", event.timestamp, event.user.display_name, dissolve::strip_html_tags(&body).join(" "))).await,
|
||||||
|
ChatEventInner::ConnectedUserInfo =>
|
||||||
|
timeline.push(format!("{} | {} connected", event.timestamp, event.user.display_name)).await,
|
||||||
|
ChatEventInner::UserJoined =>
|
||||||
|
timeline.push(format!("{} | {} joined", event.timestamp, event.user.display_name)).await,
|
||||||
|
ChatEventInner::UserParted =>
|
||||||
|
timeline.push(format!("{} | {} left", event.timestamp, event.user.display_name)).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
timeline.write().await.push("[x] stream closed".to_string());
|
||||||
|
}
|
51
src/main.rs
Normal file
51
src/main.rs
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod chat;
|
||||||
|
mod proto;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
/// simple owncast chat client to never leave your terminal
|
||||||
|
struct Args {
|
||||||
|
/// owncast server to connect to
|
||||||
|
server: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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
|
||||||
|
crossterm::terminal::enable_raw_mode()?;
|
||||||
|
let mut stdout = std::io::stdout();
|
||||||
|
crossterm::execute!(
|
||||||
|
stdout,
|
||||||
|
crossterm::terminal::EnterAlternateScreen
|
||||||
|
)?;
|
||||||
|
let backend = ratatui::backend::CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = ratatui::Terminal::new(backend)?;
|
||||||
|
terminal.hide_cursor()?;
|
||||||
|
|
||||||
|
let res = app::run(&mut terminal, timeline).await;
|
||||||
|
|
||||||
|
// restore terminal
|
||||||
|
crossterm::terminal::disable_raw_mode()?;
|
||||||
|
crossterm::execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
crossterm::terminal::LeaveAlternateScreen,
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
eprintln!("[!] Error executing app: {:?}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
63
src/proto.rs
Normal file
63
src/proto.rs
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
pub mod http {
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
#[allow(unused)]
|
||||||
|
pub struct RegisterRequest {}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[allow(unused)]
|
||||||
|
pub struct RegisterResponse {
|
||||||
|
pub id: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod ws {
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
#[allow(unused)]
|
||||||
|
pub struct ChatAction {
|
||||||
|
pub r#type: String, // CHAT, ???
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct ChatEvent {
|
||||||
|
pub id: String,
|
||||||
|
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub user: ChatUser,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub inner: ChatEventInner,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO can i avoid repeating id,timestamp,user in each msg type??
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum ChatEventInner {
|
||||||
|
#[serde(rename = "CHAT")]
|
||||||
|
Chat {
|
||||||
|
body: String,
|
||||||
|
visible: bool,
|
||||||
|
},
|
||||||
|
#[serde(rename = "CONNECTED_USER_INFO")]
|
||||||
|
ConnectedUserInfo,
|
||||||
|
#[serde(rename = "USER_JOINED")]
|
||||||
|
UserJoined,
|
||||||
|
#[serde(rename = "USER_PARTED")]
|
||||||
|
UserParted,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[allow(unused)]
|
||||||
|
pub struct ChatUser {
|
||||||
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub name_changed_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
pub id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub previous_names: Vec<String>,
|
||||||
|
pub display_color: i32,
|
||||||
|
pub is_bot: bool,
|
||||||
|
pub authenticated: bool,
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue