From 81283420997f3cb9b4bfabdd86e76eca5fa1aafa Mon Sep 17 00:00:00 2001 From: Joscha Date: Mon, 22 Aug 2022 17:12:41 +0200 Subject: [PATCH] Implement account login and logout --- CHANGELOG.md | 1 + Cargo.lock | 2 +- Cargo.toml | 4 +- src/euph/room.rs | 36 +++++++- src/ui/euph.rs | 1 + src/ui/euph/account.rs | 204 +++++++++++++++++++++++++++++++++++++++++ src/ui/euph/room.rs | 79 +++++++++++----- src/ui/input.rs | 1 + 8 files changed, 299 insertions(+), 29 deletions(-) create mode 100644 src/ui/euph/account.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7df1948..5a708b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Procedure when bumping the version number: ## Unreleased ### Added +- Account login and logout - Authentication dialog for password-protected rooms - Error popups in rooms when something goes wrong diff --git a/Cargo.lock b/Cargo.lock index c6ada51..59d76fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,7 +290,7 @@ checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be" [[package]] name = "euphoxide" version = "0.1.0" -source = "git+https://github.com/Garmelon/euphoxide.git?rev=5ac16db3fcf9a5a6705630e92f3ad859e99cd891#5ac16db3fcf9a5a6705630e92f3ad859e99cd891" +source = "git+https://github.com/Garmelon/euphoxide.git?rev=516bb8232381a7f5751cc4e0c74477b535fe46df#516bb8232381a7f5751cc4e0c74477b535fe46df" dependencies = [ "futures", "serde", diff --git a/Cargo.toml b/Cargo.toml index 8d269ed..2684c11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,10 +30,10 @@ features = ["rustls-tls-native-roots"] [dependencies.euphoxide] git = "https://github.com/Garmelon/euphoxide.git" -rev = "5ac16db3fcf9a5a6705630e92f3ad859e99cd891" +rev = "516bb8232381a7f5751cc4e0c74477b535fe46df" # [patch."https://github.com/Garmelon/euphoxide.git"] -# toss = { path = "../euphoxide/" } +# euphoxide = { path = "../euphoxide/" } [dependencies.toss] git = "https://github.com/Garmelon/toss.git" diff --git a/src/euph/room.rs b/src/euph/room.rs index 6e14669..681e2b9 100644 --- a/src/euph/room.rs +++ b/src/euph/room.rs @@ -6,7 +6,9 @@ use std::time::Duration; use anyhow::bail; use cookie::{Cookie, CookieJar}; use euphoxide::api::packet::ParsedPacket; -use euphoxide::api::{Auth, AuthOption, Data, Log, Nick, Send, Snowflake, Time, UserId}; +use euphoxide::api::{ + Auth, AuthOption, Data, Log, Login, Logout, Nick, Send, Snowflake, Time, UserId, +}; use euphoxide::conn::{ConnRx, ConnTx, Joining, Status}; use log::{error, info, warn}; use parking_lot::Mutex; @@ -49,6 +51,8 @@ enum Event { Auth(String), Nick(String), Send(Option, String, oneshot::Sender), + Login { email: String, password: String }, + Logout, } #[derive(Debug)] @@ -192,6 +196,8 @@ impl State { 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(()) @@ -361,6 +367,22 @@ impl State { }); } } + + 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)] @@ -428,4 +450,16 @@ impl Room { .map(|_| id_rx) .map_err(|_| Error::Stopped) } + + pub fn login(&self, email: String, password: String) -> Result<(), Error> { + self.event_tx + .send(Event::Login { email, password }) + .map_err(|_| Error::Stopped) + } + + pub fn logout(&self) -> Result<(), Error> { + self.event_tx + .send(Event::Logout) + .map_err(|_| Error::Stopped) + } } diff --git a/src/ui/euph.rs b/src/ui/euph.rs index 686ba1e..b18bd8b 100644 --- a/src/ui/euph.rs +++ b/src/ui/euph.rs @@ -1,3 +1,4 @@ +mod account; mod auth; mod nick; mod nick_list; diff --git a/src/ui/euph/account.rs b/src/ui/euph/account.rs new file mode 100644 index 0000000..8aa678d --- /dev/null +++ b/src/ui/euph/account.rs @@ -0,0 +1,204 @@ +use std::sync::Arc; + +use crossterm::event::KeyCode; +use euphoxide::api::PersonalAccountView; +use euphoxide::conn::Status; +use parking_lot::FairMutex; +use toss::styled::Styled; +use toss::terminal::Terminal; + +use crate::euph::Room; +use crate::ui::input::{key, InputEvent, KeyBindingsList, KeyEvent}; +use crate::ui::util; +use crate::ui::widgets::editor::EditorState; +use crate::ui::widgets::empty::Empty; +use crate::ui::widgets::join::{Segment, VJoin}; +use crate::ui::widgets::popup::Popup; +use crate::ui::widgets::text::Text; +use crate::ui::widgets::BoxedWidget; + +use super::room::RoomStatus; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Focus { + Email, + Password, +} + +pub struct LoggedOut { + focus: Focus, + email: EditorState, + password: EditorState, +} + +impl LoggedOut { + fn new() -> Self { + Self { + focus: Focus::Email, + email: EditorState::new(), + password: EditorState::new(), + } + } + + fn widget(&self) -> BoxedWidget { + VJoin::new(vec![ + Segment::new(Text::new("Email address")), + Segment::new(self.email.widget().focus(self.focus == Focus::Email)), + Segment::new(Empty::new().height(1)), + Segment::new(Text::new("Password")), + Segment::new( + self.password + .widget() + .focus(self.focus == Focus::Password) + .hidden(), + ), + ]) + .into() + } +} + +pub struct LoggedIn(PersonalAccountView); + +impl LoggedIn { + fn widget(&self) -> BoxedWidget { + let text = Styled::new_plain("Name: ") + .then_plain(&self.0.name) + .then_plain("\n") + .then_plain("Email: ") + .then_plain(&self.0.email); + Text::new(text).into() + } +} + +pub enum AccountUiState { + LoggedOut(LoggedOut), + LoggedIn(LoggedIn), +} + +pub enum EventResult { + NotHandled, + Handled, + ResetState, +} + +impl AccountUiState { + pub fn new() -> Self { + Self::LoggedOut(LoggedOut::new()) + } + + /// Returns `false` if the account UI should not be displayed any longer. + pub fn stabilize(&mut self, status: &RoomStatus) -> bool { + if let RoomStatus::Connected(Status::Joined(status)) = status { + match (&self, &status.account) { + (Self::LoggedOut(_), Some(view)) => *self = Self::LoggedIn(LoggedIn(view.clone())), + (Self::LoggedIn(_), None) => *self = Self::LoggedOut(LoggedOut::new()), + _ => {} + } + true + } else { + false + } + } + + pub fn widget(&self) -> BoxedWidget { + let inner = match self { + Self::LoggedOut(logged_out) => logged_out.widget(), + Self::LoggedIn(logged_in) => logged_in.widget(), + }; + Popup::new(inner).title("Account").build() + } + + pub fn list_key_bindings(&self, bindings: &mut KeyBindingsList) { + bindings.binding("esc", "close account ui"); + + match self { + Self::LoggedOut(logged_out) => { + match logged_out.focus { + Focus::Email => bindings.binding("enter", "focus on password"), + Focus::Password => bindings.binding("enter", "log in"), + } + bindings.binding("tab", "switch focus"); + util::list_editor_key_bindings(bindings, |c| c != '\n', false); + } + Self::LoggedIn(_) => bindings.binding("L", "log out"), + } + } + + pub fn handle_input_event( + &mut self, + terminal: &mut Terminal, + crossterm_lock: &Arc>, + event: &InputEvent, + room: &Option, + ) -> EventResult { + if let key!(Esc) = event { + return EventResult::ResetState; + } + + match self { + Self::LoggedOut(logged_out) => { + if let key!(Tab) = event { + logged_out.focus = match logged_out.focus { + Focus::Email => Focus::Password, + Focus::Password => Focus::Email, + }; + return EventResult::Handled; + } + + match logged_out.focus { + Focus::Email => { + if let key!(Enter) = event { + logged_out.focus = Focus::Password; + return EventResult::Handled; + } + + if util::handle_editor_input_event( + &logged_out.email, + terminal, + crossterm_lock, + event, + |c| c != '\n', + false, + ) { + EventResult::Handled + } else { + EventResult::NotHandled + } + } + Focus::Password => { + if let key!(Enter) = event { + if let Some(room) = room { + let _ = + room.login(logged_out.email.text(), logged_out.password.text()); + } + return EventResult::Handled; + } + + if util::handle_editor_input_event( + &logged_out.password, + terminal, + crossterm_lock, + event, + |c| c != '\n', + false, + ) { + EventResult::Handled + } else { + EventResult::NotHandled + } + } + } + } + Self::LoggedIn(_) => { + if let key!('L') = event { + if let Some(room) = room { + let _ = room.logout(); + } + EventResult::Handled + } else { + EventResult::NotHandled + } + } + } + } +} diff --git a/src/ui/euph/room.rs b/src/ui/euph/room.rs index 6ea396c..9378f36 100644 --- a/src/ui/euph/room.rs +++ b/src/ui/euph/room.rs @@ -27,6 +27,7 @@ use crate::ui::widgets::BoxedWidget; use crate::ui::UiEvent; use crate::vault::EuphVault; +use super::account::{self, AccountUiState}; use super::popup::RoomPopup; use super::{auth, nick, nick_list}; @@ -34,6 +35,7 @@ enum State { Normal, Auth(EditorState), Nick(EditorState), + Account(AccountUiState), } #[allow(clippy::large_enum_variant)] @@ -152,7 +154,7 @@ impl EuphRoom { } fn stabilize_state(&mut self, status: &RoomStatus) { - match &self.state { + match &mut self.state { State::Auth(_) if !matches!( status, @@ -167,6 +169,11 @@ impl EuphRoom { State::Nick(_) if !matches!(status, RoomStatus::Connected(Status::Joined(_))) => { self.state = State::Normal } + State::Account(account) => { + if !account.stabilize(status) { + self.state = State::Normal + } + } _ => {} } } @@ -192,6 +199,7 @@ impl EuphRoom { State::Normal => {} State::Auth(editor) => layers.push(auth::widget(editor)), State::Nick(editor) => layers.push(nick::widget(editor)), + State::Account(account) => layers.push(account.widget()), } for popup in &self.popups { @@ -276,6 +284,7 @@ impl EuphRoom { } Some(Status::Joined(_)) => { bindings.binding("n", "change nick"); + bindings.binding("A", "show account ui"); true } _ => false, @@ -323,10 +332,17 @@ impl EuphRoom { self.state = State::Auth(auth::new()); true } - Some(Status::Joined(joined)) if matches!(event, key!('n') | key!('N')) => { - self.state = State::Nick(nick::new(joined)); - true - } + Some(Status::Joined(joined)) => match event { + key!('n') | key!('N') => { + self.state = State::Nick(nick::new(joined)); + true + } + key!('A') => { + self.state = State::Account(AccountUiState::new()); + true + } + _ => false, + }, _ => false, } } else { @@ -349,6 +365,7 @@ impl EuphRoom { State::Normal => self.list_normal_key_bindings(bindings).await, State::Auth(_) => auth::list_key_bindings(bindings), State::Nick(_) => nick::list_key_bindings(bindings), + State::Account(account) => account.list_key_bindings(bindings), } } @@ -366,7 +383,7 @@ impl EuphRoom { return false; } - match &self.state { + match &mut self.state { State::Normal => { self.handle_normal_input_event(terminal, crossterm_lock, event) .await @@ -393,6 +410,16 @@ impl EuphRoom { } } } + State::Account(account) => { + match account.handle_input_event(terminal, crossterm_lock, event, &self.room) { + account::EventResult::NotHandled => false, + account::EventResult::Handled => true, + account::EventResult::ResetState => { + self.state = State::Normal; + true + } + } + } } } @@ -407,11 +434,9 @@ impl EuphRoom { } fn handle_euph_data(&mut self, data: Data) -> bool { - // These packets don't result in any noticeable change in the UI. This - // function's main purpose is to prevent pings from causing a redraw. - + // These packets don't result in any noticeable change in the UI. #[allow(clippy::match_like_matches_macro)] - match data { + let handled = match &data { Data::PingEvent(_) | Data::PingReply(_) => { // Pings are displayed nowhere in the room UI. false @@ -421,23 +446,27 @@ impl EuphRoom { // we'll get an `EuphRoomEvent::Disconnected` soon after this. false } - Data::AuthReply(reply) if !reply.success => { - // Because the euphoria API is very carefully designed with - // emphasis on consistency, authentication failures are not - // normal errors but instead error-free replies that encode - // their own error. - let description = "Failed to authenticate.".to_string(); - let reason = reply - .reason - .unwrap_or_else(|| "no idea, the server wouldn't say".to_string()); - self.popups.push_front(RoomPopup::ServerError { - description, - reason, - }); - true - } _ => true, + }; + + // Because the euphoria API is very carefully designed with emphasis on + // 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)), + _ => None, + }; + if let Some((action, reason)) = error { + let description = format!("Failed to {action}."); + let reason = reason.unwrap_or_else(|| "no idea, the server wouldn't say".to_string()); + self.popups.push_front(RoomPopup::ServerError { + description, + reason, + }); } + + handled } fn handle_euph_error(&mut self, r#type: PacketType, reason: String) -> bool { diff --git a/src/ui/input.rs b/src/ui/input.rs index aa2e619..2d1eb23 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -53,6 +53,7 @@ impl From for KeyEvent { } } +// TODO Use absolute paths #[rustfmt::skip] macro_rules! key { // key!(Paste text)