From 8dd5db5888a75f54e818985fd8255b030a3e02d0 Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 23 Jan 2023 22:35:00 +0100 Subject: [PATCH] Switch euph::Room to use euphoxide's Instance --- src/euph/room.rs | 669 ++++++++++++++--------------------------- src/ui.rs | 15 +- src/ui/euph/account.rs | 10 +- src/ui/euph/room.rs | 285 +++++++++--------- src/ui/rooms.rs | 69 +++-- 5 files changed, 408 insertions(+), 640 deletions(-) diff --git a/src/euph/room.rs b/src/euph/room.rs index f36036d..41bd7ec 100644 --- a/src/euph/room.rs +++ b/src/euph/room.rs @@ -1,187 +1,143 @@ use std::convert::Infallible; -use std::str::FromStr; -use std::sync::Arc; use std::time::Duration; -use anyhow::bail; -use cookie::{Cookie, CookieJar}; use euphoxide::api::packet::ParsedPacket; use euphoxide::api::{ - Auth, AuthOption, Data, Log, Login, Logout, MessageId, Nick, Send, Time, UserId, + Auth, AuthOption, Data, Log, Login, Logout, MessageId, Nick, Send, SendEvent, SendReply, Time, + UserId, }; -use euphoxide::conn::{Conn, ConnTx, Joining, State as ConnState}; -use log::{error, info, warn}; -use parking_lot::Mutex; -use tokio::sync::{mpsc, oneshot}; -use tokio::{select, task}; -use tokio_tungstenite::tungstenite; -use tokio_tungstenite::tungstenite::http::HeaderValue; +use euphoxide::bot::instance::{Event, Instance, InstanceConfig, Snapshot}; +use euphoxide::conn::{self, ConnTx}; +use log::{debug, error, info, warn}; +use tokio::select; +use tokio::sync::oneshot; use crate::macros::ok_or_return; -use crate::vault::{EuphRoomVault, EuphVault}; +use crate::vault::EuphRoomVault; -const TIMEOUT: Duration = Duration::from_secs(30); -const RECONNECT_INTERVAL: Duration = Duration::from_secs(60); const LOG_INTERVAL: Duration = Duration::from_secs(10); -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("room stopped")] - Stopped, -} - -pub enum EuphRoomEvent { - Connected, - Disconnected, - Packet(Box), - Stopped, -} - #[derive(Debug)] -enum Event { - // Events - Connected(ConnTx), +pub enum State { Disconnected, - Packet(Box), - // Commands - State(oneshot::Sender>), - RequestLogs, - Auth(String), - Nick(String), - Send(Option, String, oneshot::Sender), - Login { email: String, password: String }, - Logout, -} - -#[derive(Debug)] -struct State { - name: String, - username: Option, - force_username: bool, - password: Option, - vault: EuphRoomVault, - - conn_tx: Option, - /// `None` before any `snapshot-event`, then either `Some(None)` or - /// `Some(Some(id))`. - last_msg_id: Option>, - requesting_logs: Arc>, + Connecting, + Connected(ConnTx, conn::State), + Stopped, } impl State { - async fn run( - mut self, - canary: oneshot::Receiver, - event_tx: mpsc::UnboundedSender, - mut event_rx: mpsc::UnboundedReceiver, - euph_room_event_tx: mpsc::UnboundedSender, - ephemeral: bool, - ) { - let vault = self.vault.clone(); - let name = self.name.clone(); - let result = if ephemeral { - select! { - _ = canary => Ok(()), - _ = Self::reconnect(&vault, &name, &event_tx) => Ok(()), - e = self.handle_events(&mut event_rx, &euph_room_event_tx) => e, - } + pub fn conn_tx(&self) -> Option<&ConnTx> { + if let Self::Connected(conn_tx, _) = self { + Some(conn_tx) } else { - select! { - _ = canary => Ok(()), - _ = Self::reconnect(&vault, &name, &event_tx) => Ok(()), - e = self.handle_events(&mut event_rx, &euph_room_event_tx) => e, - _ = Self::regularly_request_logs(&event_tx) => Ok(()), - } - }; - - if let Err(e) = result { - error!("e&{name}: {}", e); + None } + } +} - // Ensure that whoever is using this room knows that it's gone. - // Otherwise, the users of the Room may be left in an inconsistent or - // outdated state, and the UI may not update correctly. - let _ = euph_room_event_tx.send(EuphRoomEvent::Stopped); +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("not connected to room")] + NotConnected, +} + +#[derive(Debug)] +pub struct Room { + vault: EuphRoomVault, + ephemeral: bool, + + instance: Instance, + state: State, + + /// `None` before any `snapshot-event`, then either `Some(None)` or + /// `Some(Some(id))`. Reset whenever connection is lost. + last_msg_id: Option>, + + /// `Some` while `Self::regularly_request_logs` is running. Set to `None` to + /// drop the sender and stop the task. + log_request_canary: Option>, +} + +impl Room { + pub fn new(vault: EuphRoomVault, instance_config: InstanceConfig, on_event: F) -> Self + where + F: Fn(Event) + std::marker::Send + Sync + 'static, + { + // &rl2dev's message history is broken and requesting old messages past + // a certain point results in errors. Cove should not keep retrying log + // requests when hitting that limit, so &rl2dev is always opened in + // ephemeral mode. + let ephemeral = vault.vault().vault().ephemeral() || vault.room() == "rl2dev"; + + Self { + vault, + ephemeral, + instance: instance_config.build(on_event), + state: State::Disconnected, + last_msg_id: None, + log_request_canary: None, + } } - async fn reconnect( - vault: &EuphRoomVault, - name: &str, - event_tx: &mpsc::UnboundedSender, - ) -> anyhow::Result<()> { - loop { - info!("e&{}: connecting", name); - let connected = if let Some(mut conn) = Self::connect(vault, name).await? { - info!("e&{}: connected", name); - event_tx.send(Event::Connected(conn.tx().clone()))?; + pub fn stopped(&self) -> bool { + self.instance.stopped() + } - while let Ok(packet) = conn.recv().await { - event_tx.send(Event::Packet(Box::new(packet)))?; + pub fn state(&self) -> &State { + &self.state + } + + fn conn_tx(&self) -> Result<&ConnTx, Error> { + self.state.conn_tx().ok_or(Error::NotConnected) + } + + pub fn handle_event(&mut self, event: Event) { + match event { + Event::Connecting(_) => { + self.state = State::Connecting; + + // Juuust to make sure + self.last_msg_id = None; + self.log_request_canary = None; + } + Event::Connected(_, Snapshot { conn_tx, state }) => { + if !self.ephemeral { + let (tx, rx) = oneshot::channel(); + self.log_request_canary = Some(tx); + let vault_clone = self.vault.clone(); + let conn_tx_clone = conn_tx.clone(); + debug!("{}: spawning log request task", self.instance.config().name); + tokio::task::spawn(async move { + select! { + _ = rx => {}, + _ = Self::regularly_request_logs(vault_clone, conn_tx_clone) => {}, + } + }); } - info!("e&{}: disconnected", name); - event_tx.send(Event::Disconnected)?; + self.state = State::Connected(conn_tx, state); - true - } else { - info!("e&{}: could not connect", name); - event_tx.send(Event::Disconnected)?; - false - }; - - // Only delay reconnecting if the previous attempt failed. This way, - // we'll reconnect immediately if we login or logout. - if !connected { - tokio::time::sleep(RECONNECT_INTERVAL).await; + let cookies = &*self.instance.config().server.cookies; + let cookies = cookies.lock().unwrap().clone(); + self.vault.vault().set_cookies(cookies); + } + Event::Packet(_, packet, Snapshot { conn_tx, state }) => { + self.state = State::Connected(conn_tx, state); + self.on_packet(packet); + } + Event::Disconnected(_) => { + self.state = State::Disconnected; + self.last_msg_id = None; + self.log_request_canary = None; + } + Event::Stopped(_) => { + // TODO Remove room somewhere if this happens? If it doesn't already happen during stabilization + self.state = State::Stopped; } } } - async fn get_cookies(vault: &EuphVault) -> String { - let cookie_jar = vault.cookies().await; - let cookies = cookie_jar - .iter() - .map(|c| format!("{}", c.stripped())) - .collect::>(); - cookies.join("; ") - } - - fn update_cookies(vault: &EuphVault, set_cookies: &[HeaderValue]) { - let mut cookie_jar = CookieJar::new(); - - for cookie in set_cookies { - if let Ok(cookie) = cookie.to_str() { - if let Ok(cookie) = Cookie::from_str(cookie) { - cookie_jar.add(cookie) - } - } - } - - vault.set_cookies(cookie_jar); - } - - async fn connect(vault: &EuphRoomVault, name: &str) -> anyhow::Result> { - // TODO Set user agent? - - let cookies = Self::get_cookies(vault.vault()).await; - let cookies = HeaderValue::from_str(&cookies).expect("valid cookies"); - - match Conn::connect("euphoria.io", name, true, Some(cookies), TIMEOUT).await { - Ok((rx, set_cookies)) => { - Self::update_cookies(vault.vault(), &set_cookies); - Ok(Some(rx)) - } - Err(tungstenite::Error::Http(resp)) if resp.status().is_client_error() => { - bail!("room {name} doesn't exist"); - } - Err(tungstenite::Error::Url(_) | tungstenite::Error::HttpFormat(_)) => { - bail!("format error for room {name}"); - } - Err(_) => Ok(None), - } - } - - async fn regularly_request_logs(event_tx: &mpsc::UnboundedSender) { + async fn regularly_request_logs(vault: EuphRoomVault, conn_tx: ConnTx) { // TODO Make log downloading smarter // Possible log-related mechanics. Some of these could also run in some @@ -210,322 +166,130 @@ impl State { loop { tokio::time::sleep(LOG_INTERVAL).await; - let _ = event_tx.send(Event::RequestLogs); + Self::request_logs(&vault, &conn_tx).await; } } - async fn handle_events( - &mut self, - event_rx: &mut mpsc::UnboundedReceiver, - euph_room_event_tx: &mpsc::UnboundedSender, - ) -> anyhow::Result<()> { - while let Some(event) = event_rx.recv().await { - match event { - Event::Connected(conn_tx) => { - self.conn_tx = Some(conn_tx); - let _ = euph_room_event_tx.send(EuphRoomEvent::Connected); - } - Event::Disconnected => { - self.conn_tx = None; - self.last_msg_id = None; - let _ = euph_room_event_tx.send(EuphRoomEvent::Disconnected); - } - Event::Packet(packet) => { - self.on_packet(&packet).await?; - let _ = euph_room_event_tx.send(EuphRoomEvent::Packet(packet)); - } - Event::State(reply_tx) => self.on_state(reply_tx).await, - Event::RequestLogs => self.on_request_logs(), - Event::Auth(password) => self.on_auth(password), - Event::Nick(name) => self.on_nick(name), - Event::Send(parent, content, id_tx) => self.on_send(parent, content, id_tx), - Event::Login { email, password } => self.on_login(email, password), - Event::Logout => self.on_logout(), - } - } - Ok(()) - } - - async fn own_user_id(&self) -> Option { - Some(match self.conn_tx.as_ref()?.state().await.ok()? { - ConnState::Joining(Joining { hello, .. }) => hello?.session.id, - ConnState::Joined(joined) => joined.session.id, - }) - } - - async fn on_packet(&mut self, packet: &ParsedPacket) -> anyhow::Result<()> { - let data = ok_or_return!(&packet.content, Ok(())); - match data { - Data::BounceEvent(_) => { - if let Some(password) = &self.password { - // Try to authenticate with the configured password, but no - // promises if it doesn't work. In particular, we only ever - // try this password once. - self.on_auth(password.clone()); - } - } - Data::DisconnectEvent(d) => { - warn!("e&{}: disconnected for reason {:?}", self.name, d.reason); - } - Data::HelloEvent(_) => {} - Data::JoinEvent(d) => { - info!("e&{}: {:?} joined", self.name, d.0.name); - } - Data::LoginEvent(_) => {} - Data::LogoutEvent(_) => {} - Data::NetworkEvent(d) => { - info!("e&{}: network event ({})", self.name, d.r#type); - } - Data::NickEvent(d) => { - info!("e&{}: {:?} renamed to {:?}", self.name, d.from, d.to); - } - Data::EditMessageEvent(_) => { - info!("e&{}: a message was edited", self.name); - } - Data::PartEvent(d) => { - info!("e&{}: {:?} left", self.name, d.0.name); - } - Data::PingEvent(_) => {} - Data::PmInitiateEvent(d) => { - // TODO Show info popup and automatically join PM room - info!( - "e&{}: {:?} initiated a pm from &{}", - self.name, d.from_nick, d.from_room - ); - } - Data::SendEvent(d) => { - let own_user_id = self.own_user_id().await; - if let Some(last_msg_id) = &mut self.last_msg_id { - let id = d.0.id; - self.vault - .add_msg(Box::new(d.0.clone()), *last_msg_id, own_user_id); - *last_msg_id = Some(id); - } else { - bail!("send event before snapshot event"); - } - } - Data::SnapshotEvent(d) => { - info!("e&{}: successfully joined", self.name); - self.vault.join(Time::now()); - self.last_msg_id = Some(d.log.last().map(|m| m.id)); - let own_user_id = self.own_user_id().await; - self.vault.add_msgs(d.log.clone(), None, own_user_id); - - if let Some(username) = &self.username { - if self.force_username || d.nick.is_none() { - self.on_nick(username.clone()); - } - } - } - Data::LogReply(d) => { - let own_user_id = self.own_user_id().await; - self.vault.add_msgs(d.log.clone(), d.before, own_user_id); - } - Data::SendReply(d) => { - let own_user_id = self.own_user_id().await; - if let Some(last_msg_id) = &mut self.last_msg_id { - let id = d.0.id; - self.vault - .add_msg(Box::new(d.0.clone()), *last_msg_id, own_user_id); - *last_msg_id = Some(id); - } else { - bail!("send reply before snapshot event"); - } - } - _ => {} - } - Ok(()) - } - - async fn on_state(&self, reply_tx: oneshot::Sender>) { - let state = if let Some(conn_tx) = &self.conn_tx { - conn_tx.state().await.ok() - } else { - None - }; - - let _ = reply_tx.send(state); - } - - fn on_request_logs(&self) { - if let Some(conn_tx) = &self.conn_tx { - // Check whether logs are already being requested - let mut guard = self.requesting_logs.lock(); - if *guard { - return; - } else { - *guard = true; - } - drop(guard); - - // No logs are being requested and we've reserved our spot, so let's - // request some logs! - let vault = self.vault.clone(); - let conn_tx = conn_tx.clone(); - let requesting_logs = self.requesting_logs.clone(); - task::spawn(async move { - let result = Self::request_logs(vault, conn_tx).await; - *requesting_logs.lock() = false; - result - }); - } - } - - async fn request_logs(vault: EuphRoomVault, conn_tx: ConnTx) -> anyhow::Result<()> { + async fn request_logs(vault: &EuphRoomVault, conn_tx: &ConnTx) { let before = match vault.last_span().await { - Some((None, _)) => return Ok(()), // Already at top of room history + Some((None, _)) => return, // Already at top of room history Some((Some(before), _)) => Some(before), None => None, }; + debug!("{}: requesting logs", vault.room()); + // &rl2dev's message history is broken and requesting old messages past // a certain point results in errors. By reducing the amount of messages // in each log request, we can get closer to this point. Since &rl2dev // is fairly low in activity, this should be fine. let n = if vault.room() == "rl2dev" { 50 } else { 1000 }; - let _ = conn_tx.send(Log { n, before }).await?; + let _ = conn_tx.send(Log { n, before }).await; // The code handling incoming events and replies also handles // `LogReply`s, so we don't need to do anything special here. - - Ok(()) } - fn on_auth(&self, password: String) { - if let Some(conn_tx) = &self.conn_tx { - let conn_tx = conn_tx.clone(); - task::spawn(async move { - let _ = conn_tx - .send(Auth { - r#type: AuthOption::Passcode, - passcode: Some(password), - }) - .await; - }); + fn own_user_id(&self) -> Option { + if let State::Connected(_, state) = &self.state { + Some(match state { + conn::State::Joining(joining) => joining.hello.as_ref()?.session.id.clone(), + conn::State::Joined(joined) => joined.session.id.clone(), + }) + } else { + None } } - fn on_nick(&self, name: String) { - if let Some(conn_tx) = &self.conn_tx { - let conn_tx = conn_tx.clone(); - task::spawn(async move { - let _ = conn_tx.send(Nick { name }).await; - }); - } - } - - fn on_send( - &self, - parent: Option, - content: String, - id_tx: oneshot::Sender, - ) { - if let Some(conn_tx) = &self.conn_tx { - let conn_tx = conn_tx.clone(); - task::spawn(async move { - if let Ok(reply) = conn_tx.send(Send { content, parent }).await { - let _ = id_tx.send(reply.0.id); + fn on_packet(&mut self, packet: ParsedPacket) { + let instance_name = &self.instance.config().name; + let data = ok_or_return!(&packet.content); + match data { + Data::BounceEvent(_) => { + if let Some(password) = &self.instance.config().password { + // Try to authenticate with the configured password, but no + // promises if it doesn't work. In particular, we only ever + // try this password once. + let _ = self.auth(password.clone()); } - }); + } + Data::DisconnectEvent(d) => { + warn!("{instance_name}: disconnected for reason {:?}", d.reason); + } + Data::HelloEvent(_) => {} + Data::JoinEvent(d) => { + info!("{instance_name}: {:?} joined", d.0.name); + } + Data::LoginEvent(_) => {} + Data::LogoutEvent(_) => {} + Data::NetworkEvent(d) => { + info!("{instance_name}: network event ({})", d.r#type); + } + Data::NickEvent(d) => { + info!("{instance_name}: {:?} renamed to {:?}", d.from, d.to); + } + Data::EditMessageEvent(_) => { + info!("{instance_name}: a message was edited"); + } + Data::PartEvent(d) => { + info!("{instance_name}: {:?} left", d.0.name); + } + Data::PingEvent(_) => {} + Data::PmInitiateEvent(d) => { + // TODO Show info popup and automatically join PM room + info!( + "{instance_name}: {:?} initiated a pm from &{}", + d.from_nick, d.from_room + ); + } + Data::SendEvent(SendEvent(msg)) => { + let own_user_id = self.own_user_id(); + if let Some(last_msg_id) = &mut self.last_msg_id { + self.vault + .add_msg(Box::new(msg.clone()), *last_msg_id, own_user_id); + *last_msg_id = Some(msg.id); + } + } + Data::SnapshotEvent(d) => { + info!("{instance_name}: successfully joined"); + self.vault.join(Time::now()); + self.last_msg_id = Some(d.log.last().map(|m| m.id)); + self.vault.add_msgs(d.log.clone(), None, self.own_user_id()); + } + Data::LogReply(d) => { + self.vault + .add_msgs(d.log.clone(), d.before, self.own_user_id()); + } + Data::SendReply(SendReply(msg)) => { + let own_user_id = self.own_user_id(); + if let Some(last_msg_id) = &mut self.last_msg_id { + self.vault + .add_msg(Box::new(msg.clone()), *last_msg_id, own_user_id); + *last_msg_id = Some(msg.id); + } + } + _ => {} } } - fn on_login(&self, email: String, password: String) { - if let Some(conn_tx) = &self.conn_tx { - let _ = conn_tx.send(Login { - namespace: "email".to_string(), - id: email, - password, - }); - } - } - - fn on_logout(&self) { - if let Some(conn_tx) = &self.conn_tx { - let _ = conn_tx.send(Logout); - } - } -} - -#[derive(Debug)] -pub struct Room { - #[allow(dead_code)] - canary: oneshot::Sender, - event_tx: mpsc::UnboundedSender, -} - -impl Room { - pub fn new( - vault: EuphRoomVault, - username: Option, - force_username: bool, - password: Option, - ) -> (Self, mpsc::UnboundedReceiver) { - let (canary_tx, canary_rx) = oneshot::channel(); - let (event_tx, event_rx) = mpsc::unbounded_channel(); - let (euph_room_event_tx, euph_room_event_rx) = mpsc::unbounded_channel(); - - // &rl2dev's message history is broken and requesting old messages past - // a certain point results in errors. Cove should not keep retrying log - // requests when hitting that limit, so &rl2dev is always opened in - // ephemeral mode. - let room_name = vault.room().to_string(); - let ephemeral = vault.vault().vault().ephemeral() || room_name == "rl2dev"; - - let state = State { - name: vault.room().to_string(), - username, - force_username, - password, - vault, - conn_tx: None, - last_msg_id: None, - requesting_logs: Arc::new(Mutex::new(false)), - }; - - task::spawn(state.run( - canary_rx, - event_tx.clone(), - event_rx, - euph_room_event_tx, - ephemeral, - )); - - let new_room = Self { - canary: canary_tx, - event_tx, - }; - (new_room, euph_room_event_rx) - } - - pub fn stopped(&self) -> bool { - self.event_tx.is_closed() - } - - pub async fn state(&self) -> Result, Error> { - let (tx, rx) = oneshot::channel(); - self.event_tx - .send(Event::State(tx)) - .map_err(|_| Error::Stopped)?; - rx.await.map_err(|_| Error::Stopped) - } - pub fn auth(&self, password: String) -> Result<(), Error> { - self.event_tx - .send(Event::Auth(password)) - .map_err(|_| Error::Stopped) + let _ = self.conn_tx()?.send(Auth { + r#type: AuthOption::Passcode, + passcode: Some(password), + }); + Ok(()) } pub fn log(&self) -> Result<(), Error> { - self.event_tx - .send(Event::RequestLogs) - .map_err(|_| Error::Stopped) + let conn_tx_clone = self.conn_tx()?.clone(); + let vault_clone = self.vault.clone(); + tokio::task::spawn(async move { Self::request_logs(&vault_clone, &conn_tx_clone).await }); + Ok(()) } pub fn nick(&self, name: String) -> Result<(), Error> { - self.event_tx - .send(Event::Nick(name)) - .map_err(|_| Error::Stopped) + let _ = self.conn_tx()?.send(Nick { name }); + Ok(()) } pub fn send( @@ -533,22 +297,27 @@ impl Room { parent: Option, content: String, ) -> Result, Error> { - let (id_tx, id_rx) = oneshot::channel(); - self.event_tx - .send(Event::Send(parent, content, id_tx)) - .map(|_| id_rx) - .map_err(|_| Error::Stopped) + let reply = self.conn_tx()?.send(Send { content, parent }); + let (tx, rx) = oneshot::channel(); + tokio::spawn(async move { + if let Ok(reply) = reply.await { + let _ = tx.send(reply.0.id); + } + }); + Ok(rx) } pub fn login(&self, email: String, password: String) -> Result<(), Error> { - self.event_tx - .send(Event::Login { email, password }) - .map_err(|_| Error::Stopped) + let _ = self.conn_tx()?.send(Login { + namespace: "email".to_string(), + id: email, + password, + }); + Ok(()) } pub fn logout(&self) -> Result<(), Error> { - self.event_tx - .send(Event::Logout) - .map_err(|_| Error::Stopped) + let _ = self.conn_tx()?.send(Logout); + Ok(()) } } diff --git a/src/ui.rs b/src/ui.rs index 6393212..a24061e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -17,7 +17,6 @@ use tokio::task; use toss::terminal::Terminal; use crate::config::Config; -use crate::euph::EuphRoomEvent; use crate::logger::{LogMsg, Logger}; use crate::macros::{ok_or_return, some_or_return}; use crate::vault::Vault; @@ -37,7 +36,7 @@ pub enum UiEvent { GraphemeWidthsChanged, LogChanged, Term(crossterm::event::Event), - EuphRoom { name: String, event: EuphRoomEvent }, + Euph(euphoxide::bot::instance::Event), } enum EventHandleResult { @@ -94,7 +93,7 @@ impl Ui { let mut ui = Self { event_tx: event_tx.clone(), mode: Mode::Main, - rooms: Rooms::new(config, vault, event_tx.clone()), + rooms: Rooms::new(config, vault, event_tx.clone()).await, log_chat: ChatState::new(logger), key_bindings_list: None, }; @@ -230,9 +229,8 @@ impl Ui { self.handle_term_event(terminal, crossterm_lock, event) .await } - UiEvent::EuphRoom { name, event } => { - let handled = self.handle_euph_room_event(name, event).await; - if self.mode == Mode::Main && handled { + UiEvent::Euph(event) => { + if self.rooms.handle_euph_event(event) { EventHandleResult::Redraw } else { EventHandleResult::Continue @@ -311,9 +309,4 @@ impl Ui { EventHandleResult::Continue } } - - async fn handle_euph_room_event(&mut self, name: String, event: EuphRoomEvent) -> bool { - let handled = self.rooms.handle_euph_room_event(name, event); - handled && self.mode == Mode::Main - } } diff --git a/src/ui/euph/account.rs b/src/ui/euph/account.rs index 32569f7..3112719 100644 --- a/src/ui/euph/account.rs +++ b/src/ui/euph/account.rs @@ -1,9 +1,9 @@ use crossterm::style::{ContentStyle, Stylize}; use euphoxide::api::PersonalAccountView; -use euphoxide::conn::State as ConnState; +use euphoxide::conn; use toss::terminal::Terminal; -use crate::euph::Room; +use crate::euph::{self, Room}; use crate::ui::input::{key, InputEvent, KeyBindingsList}; use crate::ui::util; use crate::ui::widgets::editor::EditorState; @@ -14,8 +14,6 @@ use crate::ui::widgets::resize::Resize; use crate::ui::widgets::text::Text; use crate::ui::widgets::BoxedWidget; -use super::room::RoomState; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Focus { Email, @@ -97,8 +95,8 @@ impl AccountUiState { } /// Returns `false` if the account UI should not be displayed any longer. - pub fn stabilize(&mut self, state: &RoomState) -> bool { - if let RoomState::Connected(ConnState::Joined(state)) = state { + pub fn stabilize(&mut self, state: Option<&euph::State>) -> bool { + if let Some(euph::State::Connected(_, conn::State::Joined(state))) = state { match (&self, &state.account) { (Self::LoggedOut(_), Some(view)) => *self = Self::LoggedIn(LoggedIn(view.clone())), (Self::LoggedIn(_), None) => *self = Self::LoggedOut(LoggedOut::new()), diff --git a/src/ui/euph/room.rs b/src/ui/euph/room.rs index da95dfb..0639a4a 100644 --- a/src/ui/euph/room.rs +++ b/src/ui/euph/room.rs @@ -3,7 +3,8 @@ use std::sync::Arc; use crossterm::style::{ContentStyle, Stylize}; use euphoxide::api::{Data, Message, MessageId, PacketType, SessionId}; -use euphoxide::conn::{Joined, Joining, SessionInfo, State as ConnState}; +use euphoxide::bot::instance::{Event, ServerConfig}; +use euphoxide::conn::{self, Joined, Joining, SessionInfo}; use parking_lot::FairMutex; use tokio::sync::oneshot::error::TryRecvError; use tokio::sync::{mpsc, oneshot}; @@ -11,8 +12,7 @@ use toss::styled::Styled; use toss::terminal::Terminal; use crate::config; -use crate::euph::{self, EuphRoomEvent}; -use crate::macros::{ok_or_return, some_or_return}; +use crate::euph; use crate::ui::chat::{ChatState, Reaction}; use crate::ui::input::{key, InputEvent, KeyBindingsList}; use crate::ui::widgets::border::Border; @@ -48,26 +48,9 @@ enum State { InspectSession(SessionInfo), } -#[allow(clippy::large_enum_variant)] -pub enum RoomState { - NoRoom, - Stopped, - Connecting, - Connected(ConnState), -} - -impl RoomState { - pub fn connecting_or_connected(&self) -> bool { - match self { - Self::NoRoom | Self::Stopped => false, - Self::Connecting | Self::Connected(_) => true, - } - } -} - pub struct EuphRoom { + server_config: ServerConfig, config: config::EuphRoom, - ui_event_tx: mpsc::UnboundedSender, room: Option, @@ -84,11 +67,13 @@ pub struct EuphRoom { impl EuphRoom { pub fn new( + server_config: ServerConfig, config: config::EuphRoom, vault: EuphRoomVault, ui_event_tx: mpsc::UnboundedSender, ) -> Self { Self { + server_config, config, ui_event_tx, room: None, @@ -109,36 +94,23 @@ impl EuphRoom { self.vault().room() } - async fn shovel_room_events( - name: String, - mut euph_room_event_rx: mpsc::UnboundedReceiver, - ui_event_tx: mpsc::UnboundedSender, - ) { - loop { - let event = some_or_return!(euph_room_event_rx.recv().await); - let event = UiEvent::EuphRoom { - name: name.clone(), - event, - }; - ok_or_return!(ui_event_tx.send(event)); - } - } - pub fn connect(&mut self) { if self.room.is_none() { - let (room, euph_room_event_rx) = euph::Room::new( + let instance_config = self + .server_config + .clone() + .room(self.vault().room().to_string()) + .username(self.config.username.clone()) + .force_username(self.config.force_username) + .password(self.config.password.clone()); + + let tx = self.ui_event_tx.clone(); + self.room = Some(euph::Room::new( self.vault().clone(), - self.config.username.clone(), - self.config.force_username, - self.config.password.clone(), - ); - - self.room = Some(room); - - tokio::task::spawn(Self::shovel_room_events( - self.name().to_string(), - euph_room_event_rx, - self.ui_event_tx.clone(), + instance_config, + move |e| { + let _ = tx.send(UiEvent::Euph(e)); + }, )); } } @@ -147,17 +119,16 @@ impl EuphRoom { self.room = None; } - pub async fn state(&self) -> RoomState { - match &self.room { - Some(room) => match room.state().await { - Ok(Some(state)) => RoomState::Connected(state), - Ok(None) => RoomState::Connecting, - Err(_) => RoomState::Stopped, - }, - None => RoomState::NoRoom, + pub fn room_state(&self) -> Option<&euph::State> { + if let Some(room) = &self.room { + Some(room.state()) + } else { + None } } + // TODO fn room_state_joined(&self) -> Option<&Joined> {} + pub fn stopped(&self) -> bool { self.room.as_ref().map(|r| r.stopped()).unwrap_or(true) } @@ -190,52 +161,55 @@ impl EuphRoom { } } - fn stabilize_focus(&mut self, state: &RoomState) { - match state { - RoomState::Connected(ConnState::Joined(_)) => {} + fn stabilize_focus(&mut self) { + match self.room_state() { + Some(euph::State::Connected(_, conn::State::Joined(_))) => {} _ => self.focus = Focus::Chat, // There is no nick list to focus on } } - fn stabilize_state(&mut self, state: &RoomState) { - match &mut self.state { - State::Auth(_) - if !matches!( - state, - RoomState::Connected(ConnState::Joining(Joining { - bounce: Some(_), - .. - })) - ) => - { - self.state = State::Normal - } - State::Nick(_) if !matches!(state, RoomState::Connected(ConnState::Joined(_))) => { - self.state = State::Normal - } - State::Account(account) => { + fn stabilize_state(&mut self) { + let room_state = self.room.as_ref().map(|r| r.state()); + match (&mut self.state, room_state) { + ( + State::Auth(_), + Some(euph::State::Connected( + _, + conn::State::Joining(Joining { + bounce: Some(_), .. + }), + )), + ) => {} // Nothing to see here + (State::Auth(_), _) => self.state = State::Normal, + + (State::Nick(_), Some(euph::State::Connected(_, conn::State::Joined(_)))) => {} + (State::Nick(_), _) => self.state = State::Normal, + + (State::Account(account), state) => { if !account.stabilize(state) { self.state = State::Normal } } + _ => {} } } - async fn stabilize(&mut self, state: &RoomState) { + async fn stabilize(&mut self) { self.stabilize_pseudo_msg().await; - self.stabilize_focus(state); - self.stabilize_state(state); + self.stabilize_focus(); + self.stabilize_state(); } pub async fn widget(&mut self) -> BoxedWidget { - let state = self.state().await; - self.stabilize(&state).await; + self.stabilize().await; - let chat = if let RoomState::Connected(ConnState::Joined(joined)) = &state { - self.widget_with_nick_list(&state, joined).await + let room_state = self.room_state(); + let chat = if let Some(euph::State::Connected(_, conn::State::Joined(joined))) = room_state + { + self.widget_with_nick_list(room_state, joined).await } else { - self.widget_without_nick_list(&state).await + self.widget_without_nick_list(room_state).await }; let mut layers = vec![chat]; @@ -257,7 +231,7 @@ impl EuphRoom { Layer::new(layers).into() } - async fn widget_without_nick_list(&self, state: &RoomState) -> BoxedWidget { + async fn widget_without_nick_list(&self, state: Option<&euph::State>) -> BoxedWidget { VJoin::new(vec![ Segment::new(Border::new( Padding::new(self.status_widget(state).await).horizontal(1), @@ -268,7 +242,11 @@ impl EuphRoom { .into() } - async fn widget_with_nick_list(&self, state: &RoomState, joined: &Joined) -> BoxedWidget { + async fn widget_with_nick_list( + &self, + state: Option<&euph::State>, + joined: &Joined, + ) -> BoxedWidget { HJoin::new(vec![ Segment::new(VJoin::new(vec![ Segment::new(Border::new( @@ -293,18 +271,21 @@ impl EuphRoom { .into() } - async fn status_widget(&self, state: &RoomState) -> BoxedWidget { + async fn status_widget(&self, state: Option<&euph::State>) -> BoxedWidget { let room_style = ContentStyle::default().bold().blue(); let mut info = Styled::new(format!("&{}", self.name()), room_style); info = match state { - RoomState::NoRoom | RoomState::Stopped => info.then_plain(", archive"), - RoomState::Connecting => info.then_plain(", connecting..."), - RoomState::Connected(ConnState::Joining(j)) if j.bounce.is_some() => { + None | Some(euph::State::Stopped) => info.then_plain(", archive"), + Some(euph::State::Disconnected) => info.then_plain(", waiting..."), + Some(euph::State::Connecting) => info.then_plain(", connecting..."), + Some(euph::State::Connected(_, conn::State::Joining(j))) if j.bounce.is_some() => { info.then_plain(", auth required") } - RoomState::Connected(ConnState::Joining(_)) => info.then_plain(", joining..."), - RoomState::Connected(ConnState::Joined(j)) => { + Some(euph::State::Connected(_, conn::State::Joining(_))) => { + info.then_plain(", joining...") + } + Some(euph::State::Connected(_, conn::State::Joined(j))) => { let nick = &j.session.name; if nick.is_empty() { info.then_plain(", present without nick") @@ -326,8 +307,11 @@ impl EuphRoom { Text::new(info).into() } - async fn list_chat_key_bindings(&self, bindings: &mut KeyBindingsList, state: &RoomState) { - let can_compose = matches!(state, RoomState::Connected(ConnState::Joined(_))); + async fn list_chat_key_bindings(&self, bindings: &mut KeyBindingsList) { + let can_compose = matches!( + self.room_state(), + Some(euph::State::Connected(_, conn::State::Joined(_))) + ); self.chat.list_key_bindings(bindings, can_compose).await; } @@ -336,9 +320,11 @@ impl EuphRoom { terminal: &mut Terminal, crossterm_lock: &Arc>, event: &InputEvent, - state: &RoomState, ) -> bool { - let can_compose = matches!(state, RoomState::Connected(ConnState::Joined(_))); + let can_compose = matches!( + self.room_state(), + Some(euph::State::Connected(_, conn::State::Joined(_))) + ); match self .chat @@ -368,17 +354,20 @@ impl EuphRoom { false } - fn list_room_key_bindings(&self, bindings: &mut KeyBindingsList, state: &RoomState) { - match state { + fn list_room_key_bindings(&self, bindings: &mut KeyBindingsList) { + match self.room_state() { // Authenticating - RoomState::Connected(ConnState::Joining(Joining { - bounce: Some(_), .. - })) => { + Some(euph::State::Connected( + _, + conn::State::Joining(Joining { + bounce: Some(_), .. + }), + )) => { bindings.binding("a", "authenticate"); } // Connected - RoomState::Connected(ConnState::Joined(_)) => { + Some(euph::State::Connected(_, conn::State::Joined(_))) => { bindings.binding("n", "change nick"); bindings.binding("m", "download more messages"); bindings.binding("A", "show account ui"); @@ -394,12 +383,15 @@ impl EuphRoom { bindings.binding("ctrl+p", "open room's plugh.de/present page"); } - async fn handle_room_input_event(&mut self, event: &InputEvent, state: &RoomState) -> bool { - match state { + async fn handle_room_input_event(&mut self, event: &InputEvent) -> bool { + match self.room_state() { // Authenticating - RoomState::Connected(ConnState::Joining(Joining { - bounce: Some(_), .. - })) => { + Some(euph::State::Connected( + _, + conn::State::Joining(Joining { + bounce: Some(_), .. + }), + )) => { if let key!('a') = event { self.state = State::Auth(auth::new()); return true; @@ -407,7 +399,7 @@ impl EuphRoom { } // Joined - RoomState::Connected(ConnState::Joined(joined)) => match event { + Some(euph::State::Connected(_, conn::State::Joined(joined))) => match event { key!('n') | key!('N') => { self.state = State::Nick(nick::new(joined.clone())); return true; @@ -463,14 +455,10 @@ impl EuphRoom { false } - async fn list_chat_focus_key_bindings( - &self, - bindings: &mut KeyBindingsList, - state: &RoomState, - ) { - self.list_room_key_bindings(bindings, state); + async fn list_chat_focus_key_bindings(&self, bindings: &mut KeyBindingsList) { + self.list_room_key_bindings(bindings); bindings.empty(); - self.list_chat_key_bindings(bindings, state).await; + self.list_chat_key_bindings(bindings).await; } async fn handle_chat_focus_input_event( @@ -478,18 +466,17 @@ impl EuphRoom { terminal: &mut Terminal, crossterm_lock: &Arc>, event: &InputEvent, - state: &RoomState, ) -> bool { // We need to handle chat input first, otherwise the other // key bindings will shadow characters in the editor. if self - .handle_chat_input_event(terminal, crossterm_lock, event, state) + .handle_chat_input_event(terminal, crossterm_lock, event) .await { return true; } - if self.handle_room_input_event(event, state).await { + if self.handle_room_input_event(event).await { return true; } @@ -502,17 +489,14 @@ impl EuphRoom { bindings.binding("i", "inspect session"); } - fn handle_nick_list_focus_input_event( - &mut self, - event: &InputEvent, - state: &RoomState, - ) -> bool { + fn handle_nick_list_focus_input_event(&mut self, event: &InputEvent) -> bool { if util::handle_list_input_event(&mut self.nick_list, event) { return true; } if let key!('i') = event { - if let RoomState::Connected(ConnState::Joined(joined)) = state { + if let Some(euph::State::Connected(_, conn::State::Joined(joined))) = self.room_state() + { if let Some(id) = self.nick_list.cursor() { if id == joined.session.session_id { self.state = @@ -532,15 +516,13 @@ impl EuphRoom { // Handled in rooms list, not here bindings.binding("esc", "leave room"); - let state = self.state().await; - match self.focus { Focus::Chat => { - if let RoomState::Connected(ConnState::Joined(_)) = state { + if let Some(euph::State::Connected(_, conn::State::Joined(_))) = self.room_state() { bindings.binding("tab", "focus on nick list"); } - self.list_chat_focus_key_bindings(bindings, &state).await; + self.list_chat_focus_key_bindings(bindings).await; } Focus::NickList => { bindings.binding("tab, esc", "focus on chat"); @@ -557,20 +539,18 @@ impl EuphRoom { crossterm_lock: &Arc>, event: &InputEvent, ) -> bool { - let state = self.state().await; - match self.focus { Focus::Chat => { // Needs to be handled first or the tab key may be shadowed // during editing. if self - .handle_chat_focus_input_event(terminal, crossterm_lock, event, &state) + .handle_chat_focus_input_event(terminal, crossterm_lock, event) .await { return true; } - if let RoomState::Connected(ConnState::Joined(_)) = state { + if let Some(euph::State::Connected(_, conn::State::Joined(_))) = self.room_state() { if let key!(Tab) = event { self.focus = Focus::NickList; return true; @@ -583,7 +563,7 @@ impl EuphRoom { return true; } - if self.handle_nick_list_focus_input_event(event, &state) { + if self.handle_nick_list_focus_input_event(event) { return true; } } @@ -690,20 +670,31 @@ impl EuphRoom { } } - pub fn handle_euph_room_event(&mut self, event: EuphRoomEvent) -> bool { - match event { - EuphRoomEvent::Connected | EuphRoomEvent::Disconnected | EuphRoomEvent::Stopped => true, - EuphRoomEvent::Packet(packet) => match packet.content { - Ok(data) => self.handle_euph_data(data), - Err(reason) => self.handle_euph_error(packet.r#type, reason), - }, + pub fn handle_event(&mut self, event: Event) -> bool { + let handled = if self.room.is_some() { + if let Event::Packet(_, packet, _) = &event { + match &packet.content { + Ok(data) => self.handle_euph_data(data), + Err(reason) => self.handle_euph_error(packet.r#type, reason), + } + } else { + true + } + } else { + false + }; + + if let Some(room) = &mut self.room { + room.handle_event(event); } + + handled } - fn handle_euph_data(&mut self, data: Data) -> bool { + fn handle_euph_data(&mut self, data: &Data) -> bool { // These packets don't result in any noticeable change in the UI. #[allow(clippy::match_like_matches_macro)] - let handled = match &data { + let handled = match data { Data::PingEvent(_) | Data::PingReply(_) => { // Pings are displayed nowhere in the room UI. false @@ -720,8 +711,10 @@ impl EuphRoom { // consistency, some failures are not normal errors but instead // error-free replies that encode their own error. let error = match data { - Data::AuthReply(reply) if !reply.success => Some(("authenticate", reply.reason)), - Data::LoginReply(reply) if !reply.success => Some(("login", reply.reason)), + Data::AuthReply(reply) if !reply.success => { + Some(("authenticate", reply.reason.clone())) + } + Data::LoginReply(reply) if !reply.success => Some(("login", reply.reason.clone())), _ => None, }; if let Some((action, reason)) = error { @@ -736,7 +729,7 @@ impl EuphRoom { handled } - fn handle_euph_error(&mut self, r#type: PacketType, reason: String) -> bool { + fn handle_euph_error(&mut self, r#type: PacketType, reason: &str) -> bool { let action = match r#type { PacketType::AuthReply => "authenticate", PacketType::NickReply => "set nick", @@ -762,7 +755,7 @@ impl EuphRoom { let description = format!("Failed to {action}."); self.popups.push_front(RoomPopup::Error { description, - reason, + reason: reason.to_string(), }); true } diff --git a/src/ui/rooms.rs b/src/ui/rooms.rs index b622394..bd03130 100644 --- a/src/ui/rooms.rs +++ b/src/ui/rooms.rs @@ -1,20 +1,21 @@ use std::collections::{HashMap, HashSet}; use std::iter; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use crossterm::style::{ContentStyle, Stylize}; use euphoxide::api::SessionType; -use euphoxide::conn::{Joined, State as ConnState}; +use euphoxide::bot::instance::{Event, ServerConfig}; +use euphoxide::conn::{self, Joined}; use parking_lot::FairMutex; use tokio::sync::mpsc; use toss::styled::Styled; use toss::terminal::Terminal; use crate::config::{Config, RoomsSortOrder}; -use crate::euph::EuphRoomEvent; +use crate::euph; use crate::vault::Vault; -use super::euph::room::{EuphRoom, RoomState}; +use super::euph::room::EuphRoom; use super::input::{key, InputEvent, KeyBindingsList}; use super::widgets::editor::EditorState; use super::widgets::join::{HJoin, Segment, VJoin}; @@ -57,15 +58,20 @@ pub struct Rooms { list: ListState, order: Order, + + euph_server_config: ServerConfig, euph_rooms: HashMap, } impl Rooms { - pub fn new( + pub async fn new( config: &'static Config, vault: Vault, ui_event_tx: mpsc::UnboundedSender, ) -> Self { + let euph_server_config = + ServerConfig::default().cookies(Arc::new(Mutex::new(vault.euph().cookies().await))); + let mut result = Self { config, vault, @@ -73,6 +79,7 @@ impl Rooms { state: State::ShowList, list: ListState::new(), order: Order::from_rooms_sort_order(config.rooms_sort_order), + euph_server_config, euph_rooms: HashMap::new(), }; @@ -90,6 +97,7 @@ impl Rooms { fn get_or_insert_room(&mut self, name: String) -> &mut EuphRoom { self.euph_rooms.entry(name.clone()).or_insert_with(|| { EuphRoom::new( + self.euph_server_config.clone(), self.config.euph_room(&name), self.vault.euph().room(name), self.ui_event_tx.clone(), @@ -236,15 +244,18 @@ impl Rooms { result.join(" ") } - fn format_room_state(state: RoomState) -> Option { + fn format_room_state(state: Option<&euph::State>) -> Option { match state { - RoomState::NoRoom | RoomState::Stopped => None, - RoomState::Connecting => Some("connecting".to_string()), - RoomState::Connected(ConnState::Joining(j)) if j.bounce.is_some() => { - Some("auth required".to_string()) - } - RoomState::Connected(ConnState::Joining(_)) => Some("joining".to_string()), - RoomState::Connected(ConnState::Joined(joined)) => Some(Self::format_pbln(&joined)), + None | Some(euph::State::Stopped) => None, + Some(euph::State::Disconnected) => Some("waiting".to_string()), + Some(euph::State::Connecting) => Some("connecting".to_string()), + Some(euph::State::Connected(_, connected)) => match connected { + conn::State::Joining(joining) if joining.bounce.is_some() => { + Some("auth required".to_string()) + } + conn::State::Joining(_) => Some("joining".to_string()), + conn::State::Joined(joined) => Some(Self::format_pbln(joined)), + }, } } @@ -256,7 +267,7 @@ impl Rooms { } } - fn format_room_info(state: RoomState, unseen: usize) -> Styled { + fn format_room_info(state: Option<&euph::State>, unseen: usize) -> Styled { let unseen_style = ContentStyle::default().bold().green(); let state = Self::format_room_state(state); @@ -276,12 +287,16 @@ impl Rooms { } } - fn sort_rooms(&self, rooms: &mut [(&String, RoomState, usize)]) { + fn sort_rooms(&self, rooms: &mut [(&String, Option<&euph::State>, usize)]) { match self.order { Order::Alphabet => rooms.sort_unstable_by_key(|(n, _, _)| *n), - Order::Importance => { - rooms.sort_unstable_by_key(|(n, s, u)| (!s.connecting_or_connected(), *u == 0, *n)) - } + Order::Importance => rooms.sort_unstable_by_key(|(n, s, u)| { + let connecting_or_connected = matches!( + s, + Some(euph::State::Connecting | euph::State::Connected(_, _)) + ); + (!connecting_or_connected, *u == 0, *n) + }), } } @@ -295,7 +310,7 @@ impl Rooms { let mut rooms = vec![]; for (name, room) in &self.euph_rooms { - let state = room.state().await; + let state = room.room_state(); let unseen = room.unseen_msgs_count().await; rooms.push((name, state, unseen)); } @@ -536,15 +551,15 @@ impl Rooms { false } - pub fn handle_euph_room_event(&mut self, name: String, event: EuphRoomEvent) -> bool { - let room_visible = if let State::ShowRoom(n) = &self.state { - *n == name - } else { - true - }; + pub fn handle_euph_event(&mut self, event: Event) -> bool { + let instance_name = event.config().name.clone(); + let room = self.get_or_insert_room(instance_name.clone()); + let handled = room.handle_event(event); - let room = self.get_or_insert_room(name); - let handled = room.handle_euph_room_event(event); + let room_visible = match &self.state { + State::ShowRoom(name) => *name == instance_name, + _ => true, + }; handled && room_visible } }