From 6023063cf42d31392b29993a31165d5d73daa040 Mon Sep 17 00:00:00 2001 From: reya <123083837+reyamir@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:10:14 +0700 Subject: [PATCH] feat: revamp the onboarding process (#205) * redesign * restructure * . * . * . * . * . --- crates/coop/src/chatspace.rs | 54 +- .../coop/src/{views/login.rs => login/mod.rs} | 867 +++++++++--------- crates/coop/src/main.rs | 8 +- crates/coop/src/new_identity/backup.rs | 217 +++++ .../new_account.rs => new_identity/mod.rs} | 301 +++--- crates/coop/src/views/backup_keys.rs | 178 ---- crates/coop/src/views/mod.rs | 3 - crates/coop/src/views/sidebar/mod.rs | 8 +- crates/coop/src/views/startup.rs | 2 +- crates/state/src/storage.rs | 4 +- crates/title_bar/src/lib.rs | 6 +- crates/ui/src/input/state.rs | 7 +- 12 files changed, 859 insertions(+), 796 deletions(-) rename crates/coop/src/{views/login.rs => login/mod.rs} (91%) create mode 100644 crates/coop/src/new_identity/backup.rs rename crates/coop/src/{views/new_account.rs => new_identity/mod.rs} (50%) delete mode 100644 crates/coop/src/views/backup_keys.rs diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs index b115f7f7..8a7254a4 100644 --- a/crates/coop/src/chatspace.rs +++ b/crates/coop/src/chatspace.rs @@ -30,13 +30,12 @@ use ui::dock_area::{ClosePanel, DockArea, DockItem}; use ui::modal::ModalButtonProps; use ui::popover::{Popover, PopoverContent}; use ui::popup_menu::PopupMenuExt; -use ui::{h_flex, v_flex, ContextModal, IconName, Root, Sizable}; +use ui::{h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt}; use crate::actions::{reset, DarkMode, KeyringPopup, Logout, Settings}; use crate::views::compose::compose_button; -use crate::views::{ - login, new_account, onboarding, preferences, sidebar, startup, user_profile, welcome, -}; +use crate::views::{onboarding, preferences, sidebar, startup, user_profile, welcome}; +use crate::{login, new_identity}; pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| ChatSpace::new(window, cx)) @@ -48,7 +47,7 @@ pub fn login(window: &mut Window, cx: &mut App) { } pub fn new_account(window: &mut Window, cx: &mut App) { - let panel = new_account::init(window, cx); + let panel = new_identity::init(window, cx); ChatSpace::set_center_panel(panel, window, cx); } @@ -496,6 +495,43 @@ impl ChatSpace { ) }) } + + fn titlebar_center(&mut self, cx: &mut Context) -> impl IntoElement { + let entity = cx.entity().downgrade(); + let panel = self.dock.read(cx).items.view(); + let title = panel.title(cx); + let id = panel.panel_id(cx); + + if id == "Onboarding" { + return div(); + }; + + h_flex() + .flex_1() + .w_full() + .justify_center() + .text_center() + .font_semibold() + .text_sm() + .child( + div().flex_1().child( + Button::new("back") + .icon(IconName::ArrowLeft) + .small() + .ghost_alt() + .rounded() + .on_click(move |_ev, window, cx| { + entity + .update(cx, |this, cx| { + this.set_onboarding_layout(window, cx); + }) + .expect("Entity has been released"); + }), + ), + ) + .child(div().flex_1().child(title)) + .child(div().flex_1()) + } } impl Render for ChatSpace { @@ -505,10 +541,16 @@ impl Render for ChatSpace { let left = self.titlebar_left(window, cx).into_any_element(); let right = self.titlebar_right(window, cx).into_any_element(); + let center = self.titlebar_center(cx).into_any_element(); + let single_panel = self.dock.read(cx).items.panel_ids(cx).is_empty(); // Update title bar children self.title_bar.update(cx, |this, _cx| { - this.set_children(vec![left, right]); + if single_panel { + this.set_children(vec![center]); + } else { + this.set_children(vec![left, right]); + } }); div() diff --git a/crates/coop/src/views/login.rs b/crates/coop/src/login/mod.rs similarity index 91% rename from crates/coop/src/views/login.rs rename to crates/coop/src/login/mod.rs index 9546b145..75e52210 100644 --- a/crates/coop/src/views/login.rs +++ b/crates/coop/src/login/mod.rs @@ -1,439 +1,428 @@ -use std::time::Duration; - -use anyhow::anyhow; -use common::BUNKER_TIMEOUT; -use gpui::prelude::FluentBuilder; -use gpui::{ - div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window, -}; -use i18n::{shared_t, t}; -use key_store::{KeyItem, KeyStore}; -use nostr_connect::prelude::*; -use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; -use theme::ActiveTheme; -use ui::button::{Button, ButtonVariants}; -use ui::dock_area::panel::{Panel, PanelEvent}; -use ui::input::{InputEvent, InputState, TextInput}; -use ui::notification::Notification; -use ui::{v_flex, ContextModal, Disableable, StyledExt}; - -use crate::actions::CoopAuthUrlHandler; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - Login::new(window, cx) -} - -pub struct Login { - key_input: Entity, - pass_input: Entity, - error: Entity>, - countdown: Entity>, - require_password: bool, - logging_in: bool, - - /// Panel - name: SharedString, - focus_handle: FocusHandle, - - /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, -} - -impl Login { - pub fn new(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| Self::view(window, cx)) - } - - fn view(window: &mut Window, cx: &mut Context) -> Self { - let key_input = cx.new(|cx| InputState::new(window, cx)); - let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true)); - - let error = cx.new(|_| None); - let countdown = cx.new(|_| None); - - let mut subscriptions = smallvec![]; - - subscriptions.push( - // Subscribe to key input events and process login when the user presses enter - cx.subscribe_in(&key_input, window, |this, input, event, window, cx| { - match event { - InputEvent::PressEnter { .. } => { - this.login(window, cx); - } - InputEvent::Change => { - if input.read(cx).value().starts_with("ncryptsec1") { - this.require_password = true; - cx.notify(); - } - } - _ => {} - }; - }), - ); - - Self { - key_input, - pass_input, - error, - countdown, - name: "Login".into(), - focus_handle: cx.focus_handle(), - logging_in: false, - require_password: false, - _subscriptions: subscriptions, - } - } - - fn login(&mut self, window: &mut Window, cx: &mut Context) { - if self.logging_in { - return; - }; - - // Prevent duplicate login requests - self.set_logging_in(true, cx); - - let value = self.key_input.read(cx).value(); - let password = self.pass_input.read(cx).value(); - - if value.starts_with("bunker://") { - self.login_with_bunker(&value, window, cx); - } else if value.starts_with("ncryptsec1") { - self.login_with_password(&value, &password, cx); - } else if value.starts_with("nsec1") { - if let Ok(secret) = SecretKey::parse(&value) { - let keys = Keys::new(secret); - self.login_with_keys(keys, cx); - } else { - self.set_error("Invalid", cx); - } - } else { - self.set_error("Invalid", cx); - } - } - - fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context) { - let Ok(uri) = NostrConnectURI::parse(content) else { - self.set_error(t!("login.bunker_invalid"), cx); - return; - }; - - let app_keys = Keys::generate(); - let timeout = Duration::from_secs(BUNKER_TIMEOUT); - let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap(); - - // Handle auth url with the default browser - signer.auth_url_handler(CoopAuthUrlHandler); - - // Start countdown - cx.spawn_in(window, async move |this, cx| { - for i in (0..=BUNKER_TIMEOUT).rev() { - if i == 0 { - this.update(cx, |this, cx| { - this.set_countdown(None, cx); - }) - .ok(); - } else { - this.update(cx, |this, cx| { - this.set_countdown(Some(i), cx); - }) - .ok(); - } - cx.background_executor().timer(Duration::from_secs(1)).await; - } - }) - .detach(); - - // Handle connection - cx.spawn_in(window, async move |this, cx| { - let result = signer.bunker_uri().await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(uri) => { - this.save_connection(&app_keys, &uri, window, cx); - this.connect(signer, cx); - } - Err(e) => { - window.push_notification(Notification::error(e.to_string()), cx); - } - }; - }) - .ok(); - }) - .detach(); - } - - fn save_connection( - &mut self, - keys: &Keys, - uri: &NostrConnectURI, - window: &mut Window, - cx: &mut Context, - ) { - let keystore = KeyStore::global(cx).read(cx).backend(); - let username = keys.public_key().to_hex(); - let secret = keys.secret_key().to_secret_bytes(); - let mut clean_uri = uri.to_string(); - - // Clear the secret parameter in the URI if it exists - if let Some(s) = uri.secret() { - clean_uri = clean_uri.replace(s, ""); - } - - cx.spawn_in(window, async move |this, cx| { - let user_url = KeyItem::User.to_string(); - let bunker_url = KeyItem::Bunker.to_string(); - let user_password = clean_uri.into_bytes(); - - // Write bunker uri to keyring for further connection - if let Err(e) = keystore - .write_credentials(&user_url, "bunker", &user_password, cx) - .await - { - this.update_in(cx, |_, window, cx| { - window.push_notification(e.to_string(), cx); - }) - .ok(); - } - - // Write the app keys for further connection - if let Err(e) = keystore - .write_credentials(&bunker_url, &username, &secret, cx) - .await - { - this.update_in(cx, |_, window, cx| { - window.push_notification(e.to_string(), cx); - }) - .ok(); - } - }) - .detach(); - } - - fn connect(&mut self, signer: NostrConnect, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - cx.background_spawn(async move { - client.set_signer(signer).await; - }) - .detach(); - } - - pub fn login_with_password(&mut self, content: &str, pwd: &str, cx: &mut Context) { - if pwd.is_empty() { - self.set_error("Password is required", cx); - return; - } - - let Ok(enc) = EncryptedSecretKey::from_bech32(content) else { - self.set_error("Secret Key is invalid", cx); - return; - }; - - let password = pwd.to_owned(); - - // Decrypt in the background to ensure it doesn't block the UI - let task = cx.background_spawn(async move { - if let Ok(content) = enc.decrypt(&password) { - Ok(Keys::new(content)) - } else { - Err(anyhow!("Invalid password")) - } - }); - - cx.spawn(async move |this, cx| { - let result = task.await; - - this.update(cx, |this, cx| { - match result { - Ok(keys) => { - this.login_with_keys(keys, cx); - } - Err(e) => { - this.set_error(e.to_string(), cx); - } - }; - }) - .ok(); - }) - .detach(); - } - - pub fn login_with_keys(&mut self, keys: Keys, cx: &mut Context) { - let keystore = KeyStore::global(cx).read(cx).backend(); - - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let username = keys.public_key().to_hex(); - let secret = keys.secret_key().to_secret_hex().into_bytes(); - - cx.spawn(async move |this, cx| { - let bunker_url = KeyItem::User.to_string(); - - // Write the app keys for further connection - if let Err(e) = keystore - .write_credentials(&bunker_url, &username, &secret, cx) - .await - { - this.update(cx, |this, cx| { - this.set_error(e.to_string(), cx); - }) - .ok(); - } - - // Update the signer - cx.background_spawn(async move { - client.set_signer(keys).await; - }) - .detach(); - }) - .detach(); - } - - fn set_error(&mut self, message: S, cx: &mut Context) - where - S: Into, - { - // Reset the log in state - self.set_logging_in(false, cx); - - // Reset the countdown - self.set_countdown(None, cx); - - // Update error message - self.error.update(cx, |this, cx| { - *this = Some(message.into()); - cx.notify(); - }); - - // Clear the error message after 3 secs - cx.spawn(async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(3)).await; - - this.update(cx, |this, cx| { - this.error.update(cx, |this, cx| { - *this = None; - cx.notify(); - }); - }) - .ok(); - }) - .detach(); - } - - fn set_logging_in(&mut self, status: bool, cx: &mut Context) { - self.logging_in = status; - cx.notify(); - } - - fn set_countdown(&mut self, i: Option, cx: &mut Context) { - self.countdown.update(cx, |this, cx| { - *this = i; - cx.notify(); - }); - } -} - -impl Panel for Login { - fn panel_id(&self) -> SharedString { - self.name.clone() - } - - fn title(&self, _cx: &App) -> AnyElement { - self.name.clone().into_any_element() - } -} - -impl EventEmitter for Login {} - -impl Focusable for Login { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for Login { - fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .relative() - .size_full() - .items_center() - .justify_center() - .child( - v_flex() - .w_96() - .gap_10() - .child( - div() - .text_center() - .child( - div() - .text_xl() - .font_semibold() - .line_height(relative(1.3)) - .child(shared_t!("login.title")), - ) - .child( - div() - .text_color(cx.theme().text_muted) - .child(shared_t!("login.key_description")), - ), - ) - .child( - v_flex() - .gap_3() - .text_sm() - .child( - v_flex() - .gap_1() - .text_sm() - .text_color(cx.theme().text_muted) - .child("nsec or bunker://") - .child(TextInput::new(&self.key_input)), - ) - .when(self.require_password, |this| { - this.child( - v_flex() - .gap_1() - .text_sm() - .text_color(cx.theme().text_muted) - .child("Password:") - .child(TextInput::new(&self.pass_input)), - ) - }) - .child( - Button::new("login") - .label(t!("common.continue")) - .primary() - .loading(self.logging_in) - .disabled(self.logging_in) - .on_click(cx.listener(move |this, _, window, cx| { - this.login(window, cx); - })), - ) - .when_some(self.countdown.read(cx).as_ref(), |this, i| { - this.child( - div() - .text_xs() - .text_center() - .text_color(cx.theme().text_muted) - .child(shared_t!("login.approve_message", i = i)), - ) - }) - .when_some(self.error.read(cx).as_ref(), |this, error| { - this.child( - div() - .text_xs() - .text_center() - .text_color(cx.theme().danger_foreground) - .child(error.clone()), - ) - }), - ), - ) - } -} +use std::time::Duration; + +use anyhow::anyhow; +use common::BUNKER_TIMEOUT; +use gpui::prelude::FluentBuilder; +use gpui::{ + div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, + Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window, +}; +use i18n::{shared_t, t}; +use key_store::{KeyItem, KeyStore}; +use nostr_connect::prelude::*; +use smallvec::{smallvec, SmallVec}; +use state::NostrRegistry; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::dock_area::panel::{Panel, PanelEvent}; +use ui::input::{InputEvent, InputState, TextInput}; +use ui::notification::Notification; +use ui::{v_flex, ContextModal, Disableable, StyledExt}; + +use crate::actions::CoopAuthUrlHandler; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Login::new(window, cx)) +} + +#[derive(Debug)] +pub struct Login { + key_input: Entity, + pass_input: Entity, + error: Entity>, + countdown: Entity>, + require_password: bool, + logging_in: bool, + + /// Panel + name: SharedString, + focus_handle: FocusHandle, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 1]>, +} + +impl Login { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let key_input = cx.new(|cx| InputState::new(window, cx)); + let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true)); + + let error = cx.new(|_| None); + let countdown = cx.new(|_| None); + + let mut subscriptions = smallvec![]; + + subscriptions.push( + // Subscribe to key input events and process login when the user presses enter + cx.subscribe_in(&key_input, window, |this, input, event, window, cx| { + match event { + InputEvent::PressEnter { .. } => { + this.login(window, cx); + } + InputEvent::Change => { + if input.read(cx).value().starts_with("ncryptsec1") { + this.require_password = true; + cx.notify(); + } + } + _ => {} + }; + }), + ); + + Self { + key_input, + pass_input, + error, + countdown, + name: "Welcome Back".into(), + focus_handle: cx.focus_handle(), + logging_in: false, + require_password: false, + _subscriptions: subscriptions, + } + } + + fn login(&mut self, window: &mut Window, cx: &mut Context) { + if self.logging_in { + return; + }; + + // Prevent duplicate login requests + self.set_logging_in(true, cx); + + let value = self.key_input.read(cx).value(); + let password = self.pass_input.read(cx).value(); + + if value.starts_with("bunker://") { + self.login_with_bunker(&value, window, cx); + } else if value.starts_with("ncryptsec1") { + self.login_with_password(&value, &password, cx); + } else if value.starts_with("nsec1") { + if let Ok(secret) = SecretKey::parse(&value) { + let keys = Keys::new(secret); + self.login_with_keys(keys, cx); + } else { + self.set_error("Invalid", cx); + } + } else { + self.set_error("Invalid", cx); + } + } + + fn login_with_bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context) { + let Ok(uri) = NostrConnectURI::parse(content) else { + self.set_error(t!("login.bunker_invalid"), cx); + return; + }; + + let app_keys = Keys::generate(); + let timeout = Duration::from_secs(BUNKER_TIMEOUT); + let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap(); + + // Handle auth url with the default browser + signer.auth_url_handler(CoopAuthUrlHandler); + + // Start countdown + cx.spawn_in(window, async move |this, cx| { + for i in (0..=BUNKER_TIMEOUT).rev() { + if i == 0 { + this.update(cx, |this, cx| { + this.set_countdown(None, cx); + }) + .ok(); + } else { + this.update(cx, |this, cx| { + this.set_countdown(Some(i), cx); + }) + .ok(); + } + cx.background_executor().timer(Duration::from_secs(1)).await; + } + }) + .detach(); + + // Handle connection + cx.spawn_in(window, async move |this, cx| { + let result = signer.bunker_uri().await; + + this.update_in(cx, |this, window, cx| { + match result { + Ok(uri) => { + this.save_connection(&app_keys, &uri, window, cx); + this.connect(signer, cx); + } + Err(e) => { + window.push_notification(Notification::error(e.to_string()), cx); + } + }; + }) + .ok(); + }) + .detach(); + } + + fn save_connection( + &mut self, + keys: &Keys, + uri: &NostrConnectURI, + window: &mut Window, + cx: &mut Context, + ) { + let keystore = KeyStore::global(cx).read(cx).backend(); + let username = keys.public_key().to_hex(); + let secret = keys.secret_key().to_secret_bytes(); + let mut clean_uri = uri.to_string(); + + // Clear the secret parameter in the URI if it exists + if let Some(s) = uri.secret() { + clean_uri = clean_uri.replace(s, ""); + } + + cx.spawn_in(window, async move |this, cx| { + let user_url = KeyItem::User.to_string(); + let bunker_url = KeyItem::Bunker.to_string(); + let user_password = clean_uri.into_bytes(); + + // Write bunker uri to keyring for further connection + if let Err(e) = keystore + .write_credentials(&user_url, "bunker", &user_password, cx) + .await + { + this.update_in(cx, |_, window, cx| { + window.push_notification(e.to_string(), cx); + }) + .ok(); + } + + // Write the app keys for further connection + if let Err(e) = keystore + .write_credentials(&bunker_url, &username, &secret, cx) + .await + { + this.update_in(cx, |_, window, cx| { + window.push_notification(e.to_string(), cx); + }) + .ok(); + } + }) + .detach(); + } + + fn connect(&mut self, signer: NostrConnect, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + cx.background_spawn(async move { + client.set_signer(signer).await; + }) + .detach(); + } + + pub fn login_with_password(&mut self, content: &str, pwd: &str, cx: &mut Context) { + if pwd.is_empty() { + self.set_error("Password is required", cx); + return; + } + + let Ok(enc) = EncryptedSecretKey::from_bech32(content) else { + self.set_error("Secret Key is invalid", cx); + return; + }; + + let password = pwd.to_owned(); + + // Decrypt in the background to ensure it doesn't block the UI + let task = cx.background_spawn(async move { + if let Ok(content) = enc.decrypt(&password) { + Ok(Keys::new(content)) + } else { + Err(anyhow!("Invalid password")) + } + }); + + cx.spawn(async move |this, cx| { + let result = task.await; + + this.update(cx, |this, cx| { + match result { + Ok(keys) => { + this.login_with_keys(keys, cx); + } + Err(e) => { + this.set_error(e.to_string(), cx); + } + }; + }) + .ok(); + }) + .detach(); + } + + pub fn login_with_keys(&mut self, keys: Keys, cx: &mut Context) { + let keystore = KeyStore::global(cx).read(cx).backend(); + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let username = keys.public_key().to_hex(); + let secret = keys.secret_key().to_secret_hex().into_bytes(); + + cx.spawn(async move |this, cx| { + let bunker_url = KeyItem::User.to_string(); + + // Write the app keys for further connection + if let Err(e) = keystore + .write_credentials(&bunker_url, &username, &secret, cx) + .await + { + this.update(cx, |this, cx| { + this.set_error(e.to_string(), cx); + }) + .ok(); + } + + // Update the signer + cx.background_spawn(async move { + client.set_signer(keys).await; + }) + .detach(); + }) + .detach(); + } + + fn set_error(&mut self, message: S, cx: &mut Context) + where + S: Into, + { + // Reset the log in state + self.set_logging_in(false, cx); + + // Reset the countdown + self.set_countdown(None, cx); + + // Update error message + self.error.update(cx, |this, cx| { + *this = Some(message.into()); + cx.notify(); + }); + + // Clear the error message after 3 secs + cx.spawn(async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(3)).await; + + this.update(cx, |this, cx| { + this.error.update(cx, |this, cx| { + *this = None; + cx.notify(); + }); + }) + .ok(); + }) + .detach(); + } + + fn set_logging_in(&mut self, status: bool, cx: &mut Context) { + self.logging_in = status; + cx.notify(); + } + + fn set_countdown(&mut self, i: Option, cx: &mut Context) { + self.countdown.update(cx, |this, cx| { + *this = i; + cx.notify(); + }); + } +} + +impl Panel for Login { + fn panel_id(&self) -> SharedString { + self.name.clone() + } + + fn title(&self, _cx: &App) -> AnyElement { + self.name.clone().into_any_element() + } +} + +impl EventEmitter for Login {} + +impl Focusable for Login { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for Login { + fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .relative() + .size_full() + .items_center() + .justify_center() + .child( + v_flex() + .w_96() + .gap_10() + .child( + div() + .text_center() + .text_xl() + .font_semibold() + .line_height(relative(1.3)) + .child(SharedString::from("Continue with Private Key or Bunker")), + ) + .child( + v_flex() + .gap_3() + .text_sm() + .child( + v_flex() + .gap_1() + .text_sm() + .text_color(cx.theme().text_muted) + .child("nsec or bunker://") + .child(TextInput::new(&self.key_input)), + ) + .when(self.require_password, |this| { + this.child( + v_flex() + .gap_1() + .text_sm() + .text_color(cx.theme().text_muted) + .child("Password:") + .child(TextInput::new(&self.pass_input)), + ) + }) + .child( + Button::new("login") + .label(t!("common.continue")) + .primary() + .loading(self.logging_in) + .disabled(self.logging_in) + .on_click(cx.listener(move |this, _, window, cx| { + this.login(window, cx); + })), + ) + .when_some(self.countdown.read(cx).as_ref(), |this, i| { + this.child( + div() + .text_xs() + .text_center() + .text_color(cx.theme().text_muted) + .child(shared_t!("login.approve_message", i = i)), + ) + }) + .when_some(self.error.read(cx).as_ref(), |this, error| { + this.child( + div() + .text_xs() + .text_center() + .text_color(cx.theme().danger_foreground) + .child(error.clone()), + ) + }), + ), + ) + } +} diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index e51e82e6..e541473e 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -11,9 +11,11 @@ use ui::Root; use crate::actions::{load_embedded_fonts, quit, Quit}; -pub(crate) mod actions; -pub(crate) mod chatspace; -pub(crate) mod views; +mod actions; +mod chatspace; +mod login; +mod new_identity; +mod views; i18n::init!(); diff --git a/crates/coop/src/new_identity/backup.rs b/crates/coop/src/new_identity/backup.rs new file mode 100644 index 00000000..2bedb0d5 --- /dev/null +++ b/crates/coop/src/new_identity/backup.rs @@ -0,0 +1,217 @@ +use std::time::Duration; + +use anyhow::{anyhow, Error}; +use common::home_dir; +use gpui::{ + div, App, AppContext, ClipboardItem, Context, Entity, Flatten, IntoElement, ParentElement, + Render, SharedString, Styled, Task, Window, +}; +use nostr_sdk::prelude::*; +use smallvec::{smallvec, SmallVec}; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::input::{InputState, TextInput}; +use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt}; + +pub fn init(keys: &Keys, window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Backup::new(keys, window, cx)) +} + +#[derive(Debug)] +pub struct Backup { + pubkey_input: Entity, + secret_input: Entity, + error: Option, + copied: bool, + + // Async operations + _tasks: SmallVec<[Task<()>; 1]>, +} + +impl Backup { + pub fn new(keys: &Keys, window: &mut Window, cx: &mut Context) -> Self { + let Ok(npub) = keys.public_key.to_bech32(); + let Ok(nsec) = keys.secret_key().to_bech32(); + + let pubkey_input = cx.new(|cx| { + InputState::new(window, cx) + .disabled(true) + .default_value(npub) + }); + + let secret_input = cx.new(|cx| { + InputState::new(window, cx) + .disabled(true) + .default_value(nsec) + }); + + Self { + pubkey_input, + secret_input, + error: None, + copied: false, + _tasks: smallvec![], + } + } + + pub fn backup(&self, window: &Window, cx: &Context) -> Task> { + let dir = home_dir(); + let path = cx.prompt_for_new_path(dir, Some("My Nostr Account")); + let nsec = self.secret_input.read(cx).value().to_string(); + + cx.spawn_in(window, async move |this, cx| { + match Flatten::flatten(path.await.map_err(|e| e.into())) { + Ok(Ok(Some(path))) => { + if let Err(e) = smol::fs::write(&path, nsec).await { + this.update_in(cx, |this, window, cx| { + this.set_error(e.to_string(), window, cx); + }) + .expect("Entity has been released"); + } else { + return Ok(()); + } + } + _ => { + log::error!("Failed to save backup keys"); + } + }; + + Err(anyhow!("Failed to backup keys")) + }) + } + + fn copy(&mut self, value: impl Into, window: &mut Window, cx: &mut Context) { + let item = ClipboardItem::new_string(value.into()); + cx.write_to_clipboard(item); + + self.set_copied(true, window, cx); + } + + fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context) { + self.copied = status; + cx.notify(); + + // Reset the copied state after a delay + if status { + self._tasks.push(cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + + this.update_in(cx, |this, window, cx| { + this.set_copied(false, window, cx); + }) + .ok(); + })); + } + } + + fn set_error(&mut self, error: E, window: &mut Window, cx: &mut Context) + where + E: Into, + { + self.error = Some(error.into()); + cx.notify(); + + // Clear the error message after a delay + self._tasks.push(cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(2)).await; + + this.update(cx, |this, cx| { + this.error = None; + cx.notify(); + }) + .ok(); + })); + } +} + +impl Render for Backup { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const DESCRIPTION: &str = "In Nostr, your account is defined by a KEY PAIR. These keys are used to sign your messages and identify you."; + const WARN: &str = "You must keep the Secret Key in a safe place. If you lose it, you will lose access to your account."; + const PK: &str = "Public Key is the address that others will use to find you."; + const SK: &str = "Secret Key provides access to your account."; + + v_flex() + .gap_2() + .text_sm() + .child(SharedString::from(DESCRIPTION)) + .child( + v_flex() + .gap_1() + .child( + div() + .font_semibold() + .child(SharedString::from("Public Key:")), + ) + .child( + h_flex() + .gap_1() + .child(TextInput::new(&self.pubkey_input).small()) + .child( + Button::new("copy-pubkey") + .icon({ + if self.copied { + IconName::CheckCircleFill + } else { + IconName::Copy + } + }) + .ghost_alt() + .disabled(self.copied) + .on_click(cx.listener(move |this, _e, window, cx| { + this.copy(this.pubkey_input.read(cx).value(), window, cx); + })), + ), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from(PK)), + ), + ) + .child(divider(cx)) + .child( + v_flex() + .gap_1() + .child( + div() + .font_semibold() + .child(SharedString::from("Secret Key:")), + ) + .child( + h_flex() + .gap_1() + .child(TextInput::new(&self.secret_input).small()) + .child( + Button::new("copy-secret") + .icon({ + if self.copied { + IconName::CheckCircleFill + } else { + IconName::Copy + } + }) + .ghost_alt() + .disabled(self.copied) + .on_click(cx.listener(move |this, _e, window, cx| { + this.copy(this.secret_input.read(cx).value(), window, cx); + })), + ), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from(SK)), + ), + ) + .child(divider(cx)) + .child( + div() + .text_xs() + .text_color(cx.theme().danger_foreground) + .child(SharedString::from(WARN)), + ) + } +} diff --git a/crates/coop/src/views/new_account.rs b/crates/coop/src/new_identity/mod.rs similarity index 50% rename from crates/coop/src/views/new_account.rs rename to crates/coop/src/new_identity/mod.rs index 424007a5..13e4cc1a 100644 --- a/crates/coop/src/views/new_account.rs +++ b/crates/coop/src/new_identity/mod.rs @@ -1,9 +1,9 @@ use anyhow::{anyhow, Error}; use common::{default_nip17_relays, default_nip65_relays, nip96_upload, BOOTSTRAP_RELAYS}; use gpui::{ - div, relative, rems, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity, - EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, - Render, SharedString, Styled, Task, WeakEntity, Window, + rems, AnyElement, App, AppContext, Context, Entity, EventEmitter, Flatten, FocusHandle, + Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task, + Window, }; use gpui_tokio::Tokio; use i18n::{shared_t, t}; @@ -12,20 +12,20 @@ use nostr_sdk::prelude::*; use settings::AppSettings; use smol::fs; use state::NostrRegistry; -use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock_area::panel::{Panel, PanelEvent}; use ui::input::{InputState, TextInput}; use ui::modal::ModalButtonProps; -use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt}; +use ui::{divider, v_flex, ContextModal, Disableable, IconName, Sizable}; -use crate::views::backup_keys::BackupKeys; +mod backup; pub fn init(window: &mut Window, cx: &mut App) -> Entity { - NewAccount::new(window, cx) + cx.new(|cx| NewAccount::new(window, cx)) } +#[derive(Debug)] pub struct NewAccount { name_input: Entity, avatar_input: Entity, @@ -38,11 +38,7 @@ pub struct NewAccount { } impl NewAccount { - pub fn new(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| Self::view(window, cx)) - } - - fn view(window: &mut Window, cx: &mut Context) -> Self { + fn new(window: &mut Window, cx: &mut Context) -> Self { let temp_keys = cx.new(|_| Keys::generate()); let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice")); let avatar_input = cx.new(|cx| InputState::new(window, cx)); @@ -53,7 +49,7 @@ impl NewAccount { temp_keys, uploading: false, submitting: false, - name: "New Account".into(), + name: "Create a new identity".into(), focus_handle: cx.focus_handle(), } } @@ -62,7 +58,7 @@ impl NewAccount { self.submitting(true, cx); let keys = self.temp_keys.read(cx).clone(); - let view = cx.new(|cx| BackupKeys::new(&keys, window, cx)); + let view = backup::init(&keys, window, cx); let weak_view = view.downgrade(); let current_view = cx.entity().downgrade(); @@ -80,20 +76,25 @@ impl NewAccount { .on_ok(move |_, window, cx| { weak_view .update(cx, |this, cx| { - let current_view = current_view.clone(); + let view = current_view.clone(); + let task = this.backup(window, cx); - if let Some(task) = this.backup(window, cx) { - cx.spawn_in(window, async move |_, cx| { - task.await; + cx.spawn_in(window, async move |_this, cx| { + let result = task.await; - current_view - .update(cx, |this, cx| { - this.set_signer(cx); + match result { + Ok(_) => { + view.update_in(cx, |this, window, cx| { + this.set_signer(window, cx); }) - .ok(); - }) - .detach(); - } + .expect("Entity has been released"); + } + Err(e) => { + log::error!("Failed to backup: {e}"); + } + } + }) + .detach(); }) .ok(); // true to close the modal @@ -102,7 +103,7 @@ impl NewAccount { }) } - pub fn set_signer(&mut self, cx: &mut Context) { + pub fn set_signer(&mut self, window: &mut Window, cx: &mut Context) { let keystore = KeyStore::global(cx).read(cx).backend(); let nostr = NostrRegistry::global(cx); @@ -120,7 +121,70 @@ impl NewAccount { metadata = metadata.picture(url); }; - cx.spawn(async move |_, cx| { + // Close all modals if available + window.close_all_modals(cx); + + // Set the client's signer with the current keys + let task: Task> = cx.background_spawn(async move { + let signer = keys.clone(); + let nip65_relays = default_nip65_relays(); + let nip17_relays = default_nip17_relays(); + + // Construct a NIP-65 event + let event = EventBuilder::new(Kind::RelayList, "") + .tags( + nip65_relays + .iter() + .cloned() + .map(|(url, metadata)| Tag::relay_metadata(url, metadata)), + ) + .sign(&signer) + .await?; + + // Set NIP-65 relays + client.send_event_to(BOOTSTRAP_RELAYS, &event).await?; + + // Extract only write relays + let write_relays: Vec = nip65_relays + .iter() + .filter_map(|(url, metadata)| { + if metadata.is_none() || metadata == &Some(RelayMetadata::Write) { + Some(url.to_owned()) + } else { + None + } + }) + .collect(); + + // Ensure relays are connected + for url in write_relays.iter() { + client.add_relay(url).await?; + client.connect_relay(url).await?; + } + + // Construct a NIP-17 event + let event = EventBuilder::new(Kind::InboxRelays, "") + .tags(nip17_relays.iter().cloned().map(Tag::relay)) + .sign(&signer) + .await?; + + // Set NIP-17 relays + client.send_event_to(&write_relays, &event).await?; + + // Construct a metadata event + let event = EventBuilder::metadata(&metadata).sign(&signer).await?; + + // Send metadata event to both write relays and bootstrap relays + client.send_event_to(&write_relays, &event).await?; + client.send_event_to(BOOTSTRAP_RELAYS, &event).await?; + + // Update the client's signer with the current keys + client.set_signer(keys).await; + + Ok(()) + }); + + cx.spawn_in(window, async move |this, cx| { let url = KeyItem::User.to_string(); // Write the app keys for further connection @@ -129,68 +193,13 @@ impl NewAccount { .await .ok(); - // Update the signer - // Set the client's signer with the current keys - let task: Task> = cx.background_spawn(async move { - // Set the client's signer with the current keys - client.set_signer(keys).await; - - // Verify the signer - let signer = client.signer().await?; - let nip65_relays = default_nip65_relays(); - - // Construct a NIP-65 event - let event = EventBuilder::new(Kind::RelayList, "") - .tags( - nip65_relays - .iter() - .cloned() - .map(|(url, metadata)| Tag::relay_metadata(url, metadata)), - ) - .sign(&signer) - .await?; - - // Set NIP-65 relays - client.send_event_to(BOOTSTRAP_RELAYS, &event).await?; - - // Ensure relays are connected - for (url, _metadata) in nip65_relays.iter() { - client.add_relay(url).await?; - client.connect_relay(url).await?; - } - - // Extract only write relays - let write_relays: Vec = nip65_relays - .iter() - .filter_map(|(url, metadata)| { - if metadata.is_none() || metadata == &Some(RelayMetadata::Write) { - Some(url.to_owned()) - } else { - None - } - }) - .take(3) - .collect(); - - // Construct a NIP-17 event - let event = EventBuilder::new(Kind::InboxRelays, "") - .tags(default_nip17_relays().iter().cloned().map(Tag::relay)) - .sign(&signer) - .await?; - - // Set NIP-17 relays - client.send_event_to(&write_relays, &event).await?; - - // Construct a metadata event - let event = EventBuilder::metadata(&metadata).sign(&signer).await?; - - // Set metadata - client.send_event_to(&write_relays, &event).await?; - - Ok(()) - }); - - task.detach(); + if let Err(e) = task.await { + this.update_in(cx, |this, window, cx| { + this.submitting(false, cx); + window.push_notification(e.to_string(), cx); + }) + .expect("Entity has been released"); + } }) .detach(); } @@ -230,39 +239,31 @@ impl NewAccount { }); cx.spawn_in(window, async move |this, cx| { - match Flatten::flatten(task.await.map_err(|e| e.into())) { - Ok(Ok(url)) => { - this.update_in(cx, |this, window, cx| { + let result = Flatten::flatten(task.await.map_err(|e| e.into())); + + this.update_in(cx, |this, window, cx| { + match result { + Ok(Ok(url)) => { this.uploading(false, cx); this.avatar_input.update(cx, |this, cx| { this.set_value(url.to_string(), window, cx); }); - }) - .ok(); - } - Ok(Err(e)) => { - Self::notify_error(cx, this, e.to_string()); - } - Err(e) => { - Self::notify_error(cx, this, e.to_string()); - } - } + } + Ok(Err(e)) => { + window.push_notification(e.to_string(), cx); + this.uploading(false, cx); + } + Err(e) => { + log::warn!("Failed to upload avatar: {e}"); + this.uploading(false, cx); + } + }; + }) + .expect("Entity has been released"); }) .detach(); } - fn notify_error(cx: &mut AsyncWindowContext, entity: WeakEntity, e: String) { - cx.update(|window, cx| { - entity - .update(cx, |this, cx| { - window.push_notification(e, cx); - this.uploading(false, cx); - }) - .ok(); - }) - .ok(); - } - fn submitting(&mut self, status: bool, cx: &mut Context) { self.submitting = status; cx.notify(); @@ -294,64 +295,48 @@ impl Focusable for NewAccount { impl Render for NewAccount { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let avatar = self.avatar_input.read(cx).value(); + v_flex() .size_full() .relative() .items_center() .justify_center() - .gap_10() - .child( - div() - .text_lg() - .text_center() - .font_semibold() - .line_height(relative(1.3)) - .child(shared_t!("new_account.title")), - ) .child( v_flex() .w_96() - .gap_4() + .gap_2() + .child( + v_flex() + .h_40() + .w_full() + .items_center() + .justify_center() + .gap_4() + .child(Avatar::new(avatar).size(rems(4.25))) + .child( + Button::new("upload") + .icon(IconName::PlusCircleFill) + .label("Add an avatar") + .xsmall() + .ghost() + .rounded() + .disabled(self.uploading) + //.loading(self.uploading) + .on_click(cx.listener(move |this, _, window, cx| { + this.upload(window, cx); + })), + ), + ) .child( v_flex() .gap_1() .text_sm() .child(shared_t!("new_account.name")) - .child(TextInput::new(&self.name_input).small()), - ) - .child( - v_flex() - .gap_1() - .child(div().text_sm().child(shared_t!("new_account.avatar"))) .child( - v_flex() - .p_1() - .h_32() - .w_full() - .items_center() - .justify_center() - .gap_2() - .rounded(cx.theme().radius) - .border_1() - .border_dashed() - .border_color(cx.theme().border) - .child( - Avatar::new(self.avatar_input.read(cx).value().to_string()) - .size(rems(2.25)), - ) - .child( - Button::new("upload") - .icon(IconName::Plus) - .label(t!("common.upload")) - .ghost() - .small() - .rounded() - .disabled(self.submitting || self.uploading) - .loading(self.uploading) - .on_click(cx.listener(move |this, _, window, cx| { - this.upload(window, cx); - })), - ), + TextInput::new(&self.name_input) + .disabled(self.submitting) + .small(), ), ) .child(divider(cx)) diff --git a/crates/coop/src/views/backup_keys.rs b/crates/coop/src/views/backup_keys.rs deleted file mode 100644 index 70036330..00000000 --- a/crates/coop/src/views/backup_keys.rs +++ /dev/null @@ -1,178 +0,0 @@ -use std::fs; -use std::time::Duration; - -use common::home_dir; -use gpui::{ - div, AppContext, ClipboardItem, Context, Entity, Flatten, IntoElement, ParentElement, Render, - SharedString, Styled, Task, Window, -}; -use i18n::{shared_t, t}; -use nostr_sdk::prelude::*; -use theme::ActiveTheme; -use ui::button::{Button, ButtonVariants}; -use ui::input::{InputState, TextInput}; -use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable}; - -pub struct BackupKeys { - pubkey_input: Entity, - secret_input: Entity, - error: Option, - copied: bool, -} - -impl BackupKeys { - pub fn new(keys: &Keys, window: &mut Window, cx: &mut Context<'_, Self>) -> Self { - let Ok(npub) = keys.public_key.to_bech32(); - let Ok(nsec) = keys.secret_key().to_bech32(); - - let pubkey_input = cx.new(|cx| { - InputState::new(window, cx) - .disabled(true) - .default_value(npub) - }); - - let secret_input = cx.new(|cx| { - InputState::new(window, cx) - .disabled(true) - .default_value(nsec) - }); - - Self { - pubkey_input, - secret_input, - error: None, - copied: false, - } - } - - pub fn backup(&mut self, window: &mut Window, cx: &mut Context) -> Option> { - let dir = home_dir(); - let path = cx.prompt_for_new_path(dir, Some("My Nostr Account")); - let nsec = self.secret_input.read(cx).value().to_string(); - - Some(cx.spawn_in(window, async move |this, cx| { - match Flatten::flatten(path.await.map_err(|e| e.into())) { - Ok(Ok(Some(path))) => { - cx.update(|window, cx| { - if let Err(e) = fs::write(&path, nsec) { - this.update(cx, |this, cx| { - this.set_error(e.to_string(), window, cx); - }) - .ok(); - } - }) - .ok(); - } - _ => { - log::error!("Failed to save backup keys"); - } - }; - })) - } - - fn copy_secret(&mut self, window: &mut Window, cx: &mut Context) { - let item = ClipboardItem::new_string(self.secret_input.read(cx).value().to_string()); - cx.write_to_clipboard(item); - - self.set_copied(true, window, cx); - } - - fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context) { - self.copied = status; - cx.notify(); - - // Reset the copied state after a delay - if status { - cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; - cx.update(|window, cx| { - this.update(cx, |this, cx| { - this.set_copied(false, window, cx); - }) - .ok(); - }) - .ok(); - }) - .detach(); - } - } - - fn set_error(&mut self, error: E, window: &mut Window, cx: &mut Context) - where - E: Into, - { - self.error = Some(error.into()); - cx.notify(); - - // Clear the error message after a delay - cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; - cx.update(|_, cx| { - this.update(cx, |this, cx| { - this.error = None; - cx.notify(); - }) - .ok(); - }) - .ok(); - }) - .detach(); - } -} - -impl Render for BackupKeys { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .gap_3() - .text_sm() - .child( - div() - .text_color(cx.theme().text_muted) - .child(shared_t!("new_account.backup_description")), - ) - .child( - v_flex() - .gap_1() - .child(shared_t!("common.pubkey")) - .child(TextInput::new(&self.pubkey_input).small()) - .child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(shared_t!("new_account.backup_pubkey_note")), - ), - ) - .child(divider(cx)) - .child( - v_flex() - .gap_1() - .child(shared_t!("common.secret")) - .child( - h_flex() - .gap_1() - .child(TextInput::new(&self.secret_input).small()) - .child( - Button::new("copy") - .icon({ - if self.copied { - IconName::CheckCircleFill - } else { - IconName::Copy - } - }) - .ghost() - .disabled(self.copied) - .on_click(cx.listener(move |this, _e, window, cx| { - this.copy_secret(window, cx); - })), - ), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(shared_t!("new_account.backup_secret_note")), - ), - ) - } -} diff --git a/crates/coop/src/views/mod.rs b/crates/coop/src/views/mod.rs index a183275b..8a759493 100644 --- a/crates/coop/src/views/mod.rs +++ b/crates/coop/src/views/mod.rs @@ -1,8 +1,5 @@ -pub mod backup_keys; pub mod compose; pub mod edit_profile; -pub mod login; -pub mod new_account; pub mod onboarding; pub mod preferences; pub mod screening; diff --git a/crates/coop/src/views/sidebar/mod.rs b/crates/coop/src/views/sidebar/mod.rs index e0585bca..d0a45195 100644 --- a/crates/coop/src/views/sidebar/mod.rs +++ b/crates/coop/src/views/sidebar/mod.rs @@ -18,7 +18,7 @@ use list_item::RoomListItem; use nostr_sdk::prelude::*; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; +use state::{NostrRegistry, GIFTWRAP_SUBSCRIPTION}; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::dock_area::panel::{Panel, PanelEvent}; @@ -535,7 +535,9 @@ impl Sidebar { let client = nostr.read(cx).client(); let task: Task, Error>> = cx.background_spawn(async move { - let subscription = client.subscription(&SubscriptionId::new("inbox")).await; + let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); + let subscription = client.subscription(&id).await; + let mut relays: Vec = vec![]; for (url, _filter) in subscription.into_iter() { @@ -812,6 +814,7 @@ impl Render for Sidebar { this.child(deferred( v_flex() .py_2() + .px_1p5() .gap_1p5() .items_center() .justify_center() @@ -835,6 +838,7 @@ impl Render for Sidebar { this.child(deferred( v_flex() .py_2() + .px_1p5() .gap_1p5() .items_center() .justify_center() diff --git a/crates/coop/src/views/startup.rs b/crates/coop/src/views/startup.rs index 5aea4ade..8829f08b 100644 --- a/crates/coop/src/views/startup.rs +++ b/crates/coop/src/views/startup.rs @@ -61,7 +61,7 @@ impl Startup { Self { credential, loading: false, - name: "Account".into(), + name: "Continue".into(), focus_handle: cx.focus_handle(), image_cache: RetainAllImageCache::new(cx), _subscriptions: subscriptions, diff --git a/crates/state/src/storage.rs b/crates/state/src/storage.rs index 978ae08a..75083dbd 100644 --- a/crates/state/src/storage.rs +++ b/crates/state/src/storage.rs @@ -78,7 +78,7 @@ impl Gossip { relays .iter() .filter_map(|(url, metadata)| { - if metadata.is_none() || metadata == &Some(RelayMetadata::Write) { + if metadata.is_none() || metadata == &Some(RelayMetadata::Read) { Some(url.to_owned()) } else { None @@ -97,7 +97,7 @@ impl Gossip { relays .iter() .filter_map(|(url, metadata)| { - if metadata.is_none() || metadata == &Some(RelayMetadata::Read) { + if metadata.is_none() || metadata == &Some(RelayMetadata::Write) { Some(url.to_owned()) } else { None diff --git a/crates/title_bar/src/lib.rs b/crates/title_bar/src/lib.rs index 08d07919..00caca37 100644 --- a/crates/title_bar/src/lib.rs +++ b/crates/title_bar/src/lib.rs @@ -91,7 +91,11 @@ impl Render for TitleBar { if window.is_fullscreen() { this.px_2() } else if cx.theme().platform_kind.is_mac() { - this.pl(px(platforms::mac::TRAFFIC_LIGHT_PADDING)).pr_2() + this.pl(px(platforms::mac::TRAFFIC_LIGHT_PADDING)) + .pr_2() + .when(children.len() <= 1, |this| { + this.pr(px(platforms::mac::TRAFFIC_LIGHT_PADDING)) + }) } else { this.px_2() } diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 134773f5..8db93b28 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -522,7 +522,7 @@ impl InputState { if new_row >= last_layout.visible_range.start { let visible_row = new_row.saturating_sub(last_layout.visible_range.start); if let Some(line) = last_layout.lines.get(visible_row) { - if let Ok(x) = line.index_for_position( + if let Ok(x) = line.closest_index_for_position( Point { x: preferred_x, y: px(0.), @@ -1655,10 +1655,11 @@ impl InputState { // Return offset by use closest_index_for_x if is single line mode. if self.mode.is_single_line() { - return rendered_line.unwrapped_layout.index_for_x(pos.x).unwrap(); + return rendered_line.unwrapped_layout.closest_index_for_x(pos.x); } - let index_result = rendered_line.index_for_position(pos, line_height); + let index_result = rendered_line.closest_index_for_position(pos, line_height); + if let Ok(v) = index_result { index += v; break;