diff --git a/assets/icons/device.svg b/assets/icons/device.svg new file mode 100644 index 0000000..8c54aef --- /dev/null +++ b/assets/icons/device.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/scan.svg b/assets/icons/scan.svg new file mode 100644 index 0000000..489cb12 --- /dev/null +++ b/assets/icons/scan.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index d6f0f45..9225b5d 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -113,7 +113,7 @@ impl ChatRegistry { subscriptions.push( // Observe the nip65 state and load chat rooms on every state change cx.observe(&nostr, |this, state, cx| { - match state.read(cx).relay_list_state() { + match state.read(cx).relay_list_state { RelayState::Idle => { this.reset(cx); } diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index e7f078d..975edab 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -876,7 +876,7 @@ impl ChatPanel { window.open_modal(cx, move |this, _window, cx| { this.show_close(true) .title(SharedString::from("Sent Reports")) - .child(v_flex().pb_4().gap_4().children({ + .child(v_flex().pb_2().gap_4().children({ let mut items = Vec::with_capacity(reports.len()); for report in reports.iter() { diff --git a/crates/coop/src/dialogs/accounts.rs b/crates/coop/src/dialogs/accounts.rs new file mode 100644 index 0000000..460f288 --- /dev/null +++ b/crates/coop/src/dialogs/accounts.rs @@ -0,0 +1,187 @@ +use anyhow::Error; +use gpui::prelude::FluentBuilder; +use gpui::{ + div, px, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, + Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, +}; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +use state::{NostrRegistry, SignerEvent}; +use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::button::{Button, ButtonVariants}; +use ui::{h_flex, v_flex, Icon, IconName, Sizable, WindowExtension}; + +use crate::dialogs::connect::ConnectSigner; +use crate::dialogs::import::ImportKey; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| AccountSelector::new(window, cx)) +} + +/// Account selector +pub struct AccountSelector { + /// The error message displayed when an error occurs. + error: Entity>, + + /// Async tasks + tasks: Vec>>, + + /// Subscription to the signer events + _subscription: Option, +} + +impl AccountSelector { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let error = cx.new(|_| None); + + // Subscribe to the signer events + let nostr = NostrRegistry::global(cx); + let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, window, cx| { + match event { + SignerEvent::Set => { + window.close_all_modals(cx); + window.refresh(); + } + SignerEvent::Error(e) => { + this.error.update(cx, |this, cx| { + *this = Some(e.into()); + cx.notify(); + }); + } + }; + }); + + Self { + error, + tasks: vec![], + _subscription: Some(subscription), + } + } + + fn login(&mut self, public_key: PublicKey, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let task = nostr.read(cx).get_signer(&public_key, cx); + let error = self.error.downgrade(); + + self.tasks.push(cx.spawn_in(window, async move |_this, cx| { + match task.await { + Ok(signer) => { + nostr.update(cx, |this, cx| { + this.set_signer(signer, cx); + }); + } + Err(e) => { + error.update(cx, |this, cx| { + *this = Some(e.to_string().into()); + cx.notify(); + })?; + } + }; + Ok(()) + })); + } + + fn open_import(&mut self, window: &mut Window, cx: &mut Context) { + let import = cx.new(|cx| ImportKey::new(window, cx)); + + window.open_modal(cx, move |this, _window, _cx| { + this.width(px(460.)) + .title("Import a Secret Key or Bunker Connection") + .show_close(true) + .pb_2() + .child(import.clone()) + }); + } + + fn open_connect(&mut self, window: &mut Window, cx: &mut Context) { + let connect = cx.new(|cx| ConnectSigner::new(window, cx)); + + window.open_modal(cx, move |this, _window, _cx| { + this.width(px(460.)) + .title("Scan QR Code to Connect") + .show_close(true) + .pb_2() + .child(connect.clone()) + }); + } +} + +impl Render for AccountSelector { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let persons = PersonRegistry::global(cx); + let nostr = NostrRegistry::global(cx); + let npubs = nostr.read(cx).npubs(); + + v_flex() + .size_full() + .gap_2() + .when_some(self.error.read(cx).as_ref(), |this, error| { + this.child( + div() + .italic() + .text_xs() + .text_center() + .text_color(cx.theme().danger_active) + .child(error.clone()), + ) + }) + .children({ + let mut items = vec![]; + + for (ix, public_key) in npubs.read(cx).iter().enumerate() { + let profile = persons.read(cx).get(public_key, cx); + + items.push( + h_flex() + .id(ix) + .group("") + .px_2() + .h_10() + .gap_2() + .w_full() + .rounded(cx.theme().radius) + .bg(cx.theme().ghost_element_background) + .hover(|this| this.bg(cx.theme().ghost_element_hover)) + .child(Avatar::new(profile.avatar()).small()) + .child(div().text_sm().child(profile.name())) + .on_click(cx.listener({ + let public_key = *public_key; + move |this, _ev, window, cx| { + this.login(public_key, window, cx); + } + })), + ); + } + + items + }) + .child(div().w_full().h_px().bg(cx.theme().border_variant)) + .child( + h_flex() + .gap_1() + .justify_end() + .w_full() + .child( + Button::new("input") + .icon(Icon::new(IconName::Usb)) + .label("Import") + .ghost() + .small() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.open_import(window, cx); + })), + ) + .child( + Button::new("qr") + .icon(Icon::new(IconName::Scan)) + .label("Scan QR to connect") + .ghost() + .small() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.open_connect(window, cx); + })), + ), + ) + } +} diff --git a/crates/coop/src/dialogs/connect.rs b/crates/coop/src/dialogs/connect.rs new file mode 100644 index 0000000..9a95d82 --- /dev/null +++ b/crates/coop/src/dialogs/connect.rs @@ -0,0 +1,115 @@ +use std::sync::Arc; +use std::time::Duration; + +use common::TextUtils; +use gpui::prelude::FluentBuilder; +use gpui::{ + div, img, px, AppContext, Context, Entity, Image, IntoElement, ParentElement, Render, + SharedString, Styled, Subscription, Window, +}; +use nostr_connect::prelude::*; +use state::{ + CoopAuthUrlHandler, NostrRegistry, SignerEvent, CLIENT_NAME, NOSTR_CONNECT_RELAY, + NOSTR_CONNECT_TIMEOUT, +}; +use theme::ActiveTheme; +use ui::v_flex; + +pub struct ConnectSigner { + /// QR Code + qr_code: Option>, + + /// Error message + error: Entity>, + + /// Subscription to the signer event + _subscription: Option, +} + +impl ConnectSigner { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let error = cx.new(|_| None); + + let nostr = NostrRegistry::global(cx); + let app_keys = nostr.read(cx).app_keys.clone(); + + let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); + let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap(); + + // Generate the nostr connect uri + let uri = NostrConnectUri::client(app_keys.public_key(), vec![relay], CLIENT_NAME); + + // Generate the nostr connect + let mut signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None).unwrap(); + + // Handle the auth request + signer.auth_url_handler(CoopAuthUrlHandler); + + // Generate a QR code for quick connection + let qr_code = uri.to_string().to_qr(); + + // Set signer in the background + nostr.update(cx, |this, cx| { + this.add_nip46_signer(&signer, cx); + }); + + // Subscribe to the signer event + let subscription = cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| { + if let SignerEvent::Error(e) = event { + this.set_error(e, cx); + } + }); + + Self { + qr_code, + error, + _subscription: Some(subscription), + } + } + + fn set_error(&mut self, message: S, cx: &mut Context) + where + S: Into, + { + self.error.update(cx, |this, cx| { + *this = Some(message.into()); + cx.notify(); + }); + } +} + +impl Render for ConnectSigner { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const MSG: &str = "Scan with any Nostr Connect-compatible app to connect"; + + v_flex() + .size_full() + .items_center() + .justify_center() + .p_4() + .when_some(self.qr_code.as_ref(), |this, qr| { + this.child( + img(qr.clone()) + .size(px(256.)) + .rounded(cx.theme().radius_lg) + .border_1() + .border_color(cx.theme().border), + ) + }) + .when_some(self.error.read(cx).as_ref(), |this, error| { + this.child( + div() + .text_xs() + .text_center() + .text_color(cx.theme().danger_active) + .child(error.clone()), + ) + }) + .child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from(MSG)), + ) + } +} diff --git a/crates/coop/src/dialogs/import.rs b/crates/coop/src/dialogs/import.rs new file mode 100644 index 0000000..5b24f30 --- /dev/null +++ b/crates/coop/src/dialogs/import.rs @@ -0,0 +1,301 @@ +use std::time::Duration; + +use anyhow::{anyhow, Error}; +use gpui::prelude::FluentBuilder; +use gpui::{ + div, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, + Subscription, Task, Window, +}; +use nostr_connect::prelude::*; +use smallvec::{smallvec, SmallVec}; +use state::{CoopAuthUrlHandler, NostrRegistry, SignerEvent}; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::input::{InputEvent, InputState, TextInput}; +use ui::{v_flex, Disableable}; + +#[derive(Debug)] +pub struct ImportKey { + /// Secret key input + key_input: Entity, + + /// Password input (if required) + pass_input: Entity, + + /// Error message + error: Entity>, + + /// Countdown timer for nostr connect + countdown: Entity>, + + /// Whether the user is currently loading + loading: bool, + + /// Async tasks + tasks: Vec>>, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 2]>, +} + +impl ImportKey { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); + let key_input = cx.new(|cx| InputState::new(window, cx).masked(true)); + 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| { + if let InputEvent::PressEnter { .. } = event { + this.login(window, cx); + }; + }), + ); + + subscriptions.push( + // Subscribe to the nostr signer event + cx.subscribe_in(&nostr, window, |this, _state, event, _window, cx| { + if let SignerEvent::Error(e) = event { + this.set_error(e, cx); + } + }), + ); + + Self { + key_input, + pass_input, + error, + countdown, + loading: false, + tasks: vec![], + _subscriptions: subscriptions, + } + } + + fn login(&mut self, window: &mut Window, cx: &mut Context) { + if self.loading { + return; + }; + // Prevent duplicate login requests + self.set_loading(true, cx); + + let value = self.key_input.read(cx).value(); + let password = self.pass_input.read(cx).value(); + + if value.starts_with("bunker://") { + self.bunker(&value, window, cx); + return; + } + + if value.starts_with("ncryptsec1") { + self.ncryptsec(value, password, window, cx); + return; + } + + if let Ok(secret) = SecretKey::parse(&value) { + let keys = Keys::new(secret); + let nostr = NostrRegistry::global(cx); + + // Update the signer + nostr.update(cx, |this, cx| { + this.set_signer(keys, cx); + }); + } else { + self.set_error("Invalid key", cx); + } + } + + fn bunker(&mut self, content: &str, window: &mut Window, cx: &mut Context) { + let Ok(uri) = NostrConnectUri::parse(content) else { + self.set_error("Bunker is not valid", cx); + return; + }; + + let nostr = NostrRegistry::global(cx); + let app_keys = nostr.read(cx).app_keys.clone(); + let timeout = Duration::from_secs(30); + + // Construct the nostr connect signer + let mut signer = NostrConnect::new(uri, app_keys.clone(), timeout, None).unwrap(); + + // Handle auth url with the default browser + signer.auth_url_handler(CoopAuthUrlHandler); + + // Set signer in the background + nostr.update(cx, |this, cx| { + this.add_nip46_signer(&signer, cx); + }); + + // Start countdown + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + for i in (0..=30).rev() { + if i == 0 { + this.update(cx, |this, cx| { + this.set_countdown(None, cx); + })?; + } else { + this.update(cx, |this, cx| { + this.set_countdown(Some(i), cx); + })?; + } + cx.background_executor().timer(Duration::from_secs(1)).await; + } + Ok(()) + })); + } + + fn ncryptsec(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context) + where + S: Into, + { + let nostr = NostrRegistry::global(cx); + let content: String = content.into(); + let password: String = pwd.into(); + + if password.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; + }; + + // 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")) + } + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(keys) => { + nostr.update(cx, |this, cx| { + this.add_key_signer(&keys, cx); + }); + } + Err(e) => { + this.update(cx, |this, cx| { + this.set_error(e.to_string(), cx); + })?; + } + } + + Ok(()) + })); + } + + fn set_error(&mut self, message: S, cx: &mut Context) + where + S: Into, + { + // Reset the log in state + self.set_loading(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 + self.tasks.push(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(()) + })); + } + + fn set_loading(&mut self, status: bool, cx: &mut Context) { + self.loading = status; + cx.notify(); + } + + fn set_countdown(&mut self, i: Option, cx: &mut Context) { + self.countdown.update(cx, |this, cx| { + *this = i; + cx.notify(); + }); + } +} + +impl Render for ImportKey { + fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .p_4() + .gap_2() + .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.key_input.read(cx).value().starts_with("ncryptsec1"), + |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("Continue") + .primary() + .loading(self.loading) + .disabled(self.loading) + .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(SharedString::from(format!( + "Approve connection request from your signer in {} seconds", + i + ))), + ) + }) + .when_some(self.error.read(cx).as_ref(), |this, error| { + this.child( + div() + .text_xs() + .text_center() + .text_color(cx.theme().danger_active) + .child(error.clone()), + ) + }) + } +} diff --git a/crates/coop/src/dialogs/mod.rs b/crates/coop/src/dialogs/mod.rs index d18f277..e98dec8 100644 --- a/crates/coop/src/dialogs/mod.rs +++ b/crates/coop/src/dialogs/mod.rs @@ -1,2 +1,6 @@ +pub mod accounts; pub mod screening; pub mod settings; + +mod connect; +mod import; diff --git a/crates/coop/src/dialogs/screening.rs b/crates/coop/src/dialogs/screening.rs index 208f5ad..07e5af0 100644 --- a/crates/coop/src/dialogs/screening.rs +++ b/crates/coop/src/dialogs/screening.rs @@ -254,7 +254,7 @@ impl Screening { let total = contacts.len(); this.title(SharedString::from("Mutual contacts")).child( - v_flex().gap_1().pb_4().child( + v_flex().gap_1().pb_2().child( uniform_list("contacts", total, move |range, _window, cx| { let persons = PersonRegistry::global(cx); let mut items = Vec::with_capacity(total); @@ -356,9 +356,9 @@ impl Render for Screening { .child( Button::new("report") .tooltip("Report as a scam or impostor") - .icon(IconName::Boom) + .icon(IconName::Warning) .small() - .danger() + .warning() .rounded() .on_click(cx.listener(move |this, _e, window, cx| { this.report(window, cx); diff --git a/crates/coop/src/panels/connect.rs b/crates/coop/src/panels/connect.rs deleted file mode 100644 index 398eb1b..0000000 --- a/crates/coop/src/panels/connect.rs +++ /dev/null @@ -1,127 +0,0 @@ -use std::sync::Arc; - -use common::TextUtils; -use gpui::prelude::FluentBuilder; -use gpui::{ - div, img, px, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, - FocusHandle, Focusable, Image, IntoElement, ParentElement, Render, SharedString, Styled, Task, - Window, -}; -use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; -use theme::ActiveTheme; -use ui::dock_area::panel::{Panel, PanelEvent}; -use ui::dock_area::ClosePanel; -use ui::notification::Notification; -use ui::{v_flex, StyledExt, WindowExtension}; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| ConnectPanel::new(window, cx)) -} - -pub struct ConnectPanel { - name: SharedString, - focus_handle: FocusHandle, - - /// QR Code - qr_code: Option>, - - /// Background tasks - _tasks: SmallVec<[Task<()>; 1]>, -} - -impl ConnectPanel { - fn new(window: &mut Window, cx: &mut Context) -> Self { - let nostr = NostrRegistry::global(cx); - let weak_state = nostr.downgrade(); - let (signer, uri) = nostr.read(cx).client_connect(None); - - // Generate a QR code for quick connection - let qr_code = uri.to_string().to_qr(); - - let mut tasks = smallvec![]; - - tasks.push( - // Wait for nostr connect - cx.spawn_in(window, async move |_this, cx| { - let result = signer.bunker_uri().await; - - weak_state - .update_in(cx, |this, window, cx| { - match result { - Ok(uri) => { - this.persist_bunker(uri, cx); - this.set_signer(signer, true, cx); - // Close the current panel after setting the signer - window.dispatch_action(Box::new(ClosePanel), cx); - } - Err(e) => { - window.push_notification(Notification::error(e.to_string()), cx); - } - }; - }) - .ok(); - }), - ); - - Self { - name: "Nostr Connect".into(), - focus_handle: cx.focus_handle(), - qr_code, - _tasks: tasks, - } - } -} - -impl Panel for ConnectPanel { - fn panel_id(&self) -> SharedString { - self.name.clone() - } - - fn title(&self, _cx: &App) -> AnyElement { - self.name.clone().into_any_element() - } -} - -impl EventEmitter for ConnectPanel {} - -impl Focusable for ConnectPanel { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for ConnectPanel { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .size_full() - .items_center() - .justify_center() - .p_2() - .gap_10() - .child( - v_flex() - .justify_center() - .items_center() - .text_center() - .child( - div() - .font_semibold() - .line_height(relative(1.25)) - .child(SharedString::from("Continue with Nostr Connect")), - ) - .child(div().text_sm().text_color(cx.theme().text_muted).child( - SharedString::from("Use Nostr Connect apps to scan the code"), - )), - ) - .when_some(self.qr_code.as_ref(), |this, qr| { - this.child( - img(qr.clone()) - .size(px(256.)) - .rounded(cx.theme().radius_lg) - .border_1() - .border_color(cx.theme().border), - ) - }) - } -} diff --git a/crates/coop/src/panels/greeter.rs b/crates/coop/src/panels/greeter.rs index 44276fb..aa47072 100644 --- a/crates/coop/src/panels/greeter.rs +++ b/crates/coop/src/panels/greeter.rs @@ -86,7 +86,7 @@ impl Render for GreeterPanel { let nip17 = chat.read(cx).state(cx); let nostr = NostrRegistry::global(cx); - let nip65 = nostr.read(cx).relay_list_state(); + let nip65 = nostr.read(cx).relay_list_state.clone(); let required_actions = nip65 == RelayState::NotConfigured || nip17 == InboxState::RelayNotAvailable; @@ -188,48 +188,6 @@ impl Render for GreeterPanel { ), ) }) - .child( - v_flex() - .gap_2() - .w_full() - .child( - h_flex() - .gap_2() - .w_full() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Use your own identity")) - .child(div().flex_1().h_px().bg(cx.theme().border)), - ) - .child( - v_flex() - .gap_2() - .w_full() - .child( - Button::new("connect") - .icon(Icon::new(IconName::Door)) - .label("Connect account via Nostr Connect") - .ghost() - .small() - .justify_start() - .on_click(move |_ev, window, cx| { - // - }), - ) - .child( - Button::new("import") - .icon(Icon::new(IconName::Usb)) - .label("Import a secret key or bunker") - .ghost() - .small() - .justify_start() - .on_click(move |_ev, window, cx| { - // - }), - ), - ), - ) .child( v_flex() .gap_2() diff --git a/crates/coop/src/panels/import.rs b/crates/coop/src/panels/import.rs deleted file mode 100644 index 3d22161..0000000 --- a/crates/coop/src/panels/import.rs +++ /dev/null @@ -1,371 +0,0 @@ -use std::time::Duration; - -use anyhow::anyhow; -use gpui::prelude::FluentBuilder; -use gpui::{ - div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window, -}; -use nostr_connect::prelude::*; -use smallvec::{smallvec, SmallVec}; -use state::{CoopAuthUrlHandler, NostrRegistry}; -use theme::ActiveTheme; -use ui::button::{Button, ButtonVariants}; -use ui::dock_area::panel::{Panel, PanelEvent}; -use ui::dock_area::ClosePanel; -use ui::input::{InputEvent, InputState, TextInput}; -use ui::notification::Notification; -use ui::{v_flex, Disableable, StyledExt, WindowExtension}; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| ImportPanel::new(window, cx)) -} - -#[derive(Debug)] -pub struct ImportPanel { - name: SharedString, - focus_handle: FocusHandle, - - /// Secret key input - key_input: Entity, - - /// Password input (if required) - pass_input: Entity, - - /// Error message - error: Entity>, - - /// Countdown timer for nostr connect - countdown: Entity>, - - /// Whether the user is currently logging in - logging_in: bool, - - /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, -} - -impl ImportPanel { - fn new(window: &mut Window, cx: &mut Context) -> Self { - let key_input = cx.new(|cx| InputState::new(window, cx).masked(true)); - 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| { - if let InputEvent::PressEnter { .. } = event { - this.login(window, cx); - }; - }), - ); - - Self { - key_input, - pass_input, - error, - countdown, - name: "Import".into(), - focus_handle: cx.focus_handle(), - logging_in: 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); - return; - } - - if value.starts_with("ncryptsec1") { - self.login_with_password(&value, &password, window, cx); - return; - } - - if let Ok(secret) = SecretKey::parse(&value) { - let keys = Keys::new(secret); - let nostr = NostrRegistry::global(cx); - // Update the signer - nostr.update(cx, |this, cx| { - this.set_signer(keys, true, cx); - }); - // Close the current panel after setting the signer - window.dispatch_action(Box::new(ClosePanel), 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("Bunker is not valid", cx); - return; - }; - - let nostr = NostrRegistry::global(cx); - let weak_state = nostr.downgrade(); - - let app_keys = nostr.read(cx).app_keys(); - let timeout = Duration::from_secs(30); - 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..=30).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; - - weak_state - .update_in(cx, |this, window, cx| { - match result { - Ok(uri) => { - this.persist_bunker(uri, cx); - this.set_signer(signer, true, cx); - // Close the current panel after setting the signer - window.dispatch_action(Box::new(ClosePanel), cx); - } - Err(e) => { - window.push_notification(Notification::error(e.to_string()), cx); - } - }; - }) - .ok(); - }) - .detach(); - } - - pub fn login_with_password( - &mut self, - content: &str, - pwd: &str, - window: &mut Window, - 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_in(window, async move |this, cx| { - let result = task.await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(keys) => { - let nostr = NostrRegistry::global(cx); - // Update the signer - nostr.update(cx, |this, cx| { - this.set_signer(keys, true, cx); - }); - // Close the current panel after setting the signer - window.dispatch_action(Box::new(ClosePanel), cx); - } - Err(e) => { - this.set_error(e.to_string(), cx); - } - }; - }) - .ok(); - }) - .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 ImportPanel { - fn panel_id(&self) -> SharedString { - self.name.clone() - } - - fn title(&self, _cx: &App) -> AnyElement { - self.name.clone().into_any_element() - } -} - -impl EventEmitter for ImportPanel {} - -impl Focusable for ImportPanel { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for ImportPanel { - fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { - const SECRET_WARN: &str = "* Coop doesn't store your secret key. \ - It will be cleared when you close the app. \ - To persist your identity, please connect via Nostr Connect."; - - v_flex() - .size_full() - .items_center() - .justify_center() - .p_2() - .gap_10() - .child( - div() - .text_center() - .font_semibold() - .line_height(relative(1.25)) - .child(SharedString::from("Import a Secret Key or Bunker")), - ) - .child( - v_flex() - .w_112() - .gap_2() - .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.key_input.read(cx).value().starts_with("ncryptsec1"), - |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("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(SharedString::from(format!( - "Approve connection request from your signer in {} seconds", - 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()), - ) - }) - .child( - div() - .mt_2() - .italic() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from(SECRET_WARN)), - ), - ) - } -} diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs index bf0e0f9..9139e59 100644 --- a/crates/coop/src/workspace.rs +++ b/crates/coop/src/workspace.rs @@ -23,7 +23,7 @@ use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::notification::Notification; use ui::{h_flex, v_flex, IconName, Root, Sizable, WindowExtension}; -use crate::dialogs::settings; +use crate::dialogs::{accounts, settings}; use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list}; use crate::sidebar; @@ -64,11 +64,13 @@ pub struct Workspace { dock: Entity, /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 3]>, + _subscriptions: SmallVec<[Subscription; 4]>, } impl Workspace { fn new(window: &mut Window, cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); + let npubs = nostr.read(cx).npubs(); let chat = ChatRegistry::global(cx); let titlebar = cx.new(|_| TitleBar::new()); let dock = cx.new(|cx| DockArea::new(window, cx)); @@ -82,6 +84,24 @@ impl Workspace { }), ); + subscriptions.push( + // Observe the nostr entity + cx.observe_in(&nostr, window, move |this, nostr, window, cx| { + if nostr.read(cx).connected { + this.set_layout(window, cx); + } + }), + ); + + subscriptions.push( + // Observe the npubs entity + cx.observe_in(&npubs, window, move |this, npubs, window, cx| { + if !npubs.read(cx).is_empty() { + this.account_selector(window, cx); + } + }), + ); + subscriptions.push( // Observe all events emitted by the chat registry cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| { @@ -126,11 +146,6 @@ impl Workspace { }), ); - // Set the default layout for app's dock - cx.defer_in(window, |this, window, cx| { - this.set_layout(window, cx); - }); - Self { titlebar, dock, @@ -206,7 +221,7 @@ impl Workspace { window.open_modal(cx, move |this, _window, _cx| { this.width(px(520.)) .show_close(true) - .pb_4() + .pb_2() .title("Preferences") .child(view.clone()) }); @@ -341,6 +356,20 @@ impl Workspace { }); } + fn account_selector(&mut self, window: &mut Window, cx: &mut Context) { + let accounts = accounts::init(window, cx); + + window.open_modal(cx, move |this, _window, _cx| { + this.width(px(520.)) + .title("Continue with") + .show_close(false) + .keyboard(false) + .overlay_closable(false) + .pb_2() + .child(accounts.clone()) + }); + } + fn theme_selector(&mut self, window: &mut Window, cx: &mut Context) { window.open_modal(cx, move |this, _window, cx| { let registry = ThemeRegistry::global(cx); @@ -349,20 +378,22 @@ impl Workspace { this.width(px(520.)) .show_close(true) .title("Select theme") - .pb_4() + .pb_2() .child(v_flex().gap_2().w_full().children({ let mut items = vec![]; for (ix, (path, theme)) in themes.iter().enumerate() { items.push( h_flex() + .id(ix) .group("") .px_2() .h_8() .w_full() .justify_between() .rounded(cx.theme().radius) - .hover(|this| this.bg(cx.theme().elevated_surface_background)) + .bg(cx.theme().ghost_element_background) + .hover(|this| this.bg(cx.theme().ghost_element_hover)) .child( h_flex() .gap_1p5() @@ -485,12 +516,12 @@ impl Workspace { }), ) }) - .when(nostr.read(cx).creating(), |this| { + .when(nostr.read(cx).creating, |this| { this.child(div().text_xs().text_color(cx.theme().text_muted).child( SharedString::from("Coop is creating a new identity for you..."), )) }) - .when(!nostr.read(cx).connected(), |this| { + .when(!nostr.read(cx).connected, |this| { this.child( div() .text_xs() @@ -503,7 +534,6 @@ impl Workspace { fn titlebar_right(&mut self, _window: &mut Window, cx: &Context) -> impl IntoElement { let nostr = NostrRegistry::global(cx); let signer = nostr.read(cx).signer(); - let relay_list = nostr.read(cx).relay_list_state(); let chat = ChatRegistry::global(cx); let inbox_state = chat.read(cx).state(cx); @@ -633,7 +663,7 @@ impl Workspace { div() .text_xs() .text_color(cx.theme().text_muted) - .map(|this| match relay_list { + .map(|this| match nostr.read(cx).relay_list_state { RelayState::Checking => this .child(div().child(SharedString::from( "Fetching user's relay list...", @@ -652,7 +682,9 @@ impl Workspace { .tooltip("User's relay list") .small() .ghost() - .when(relay_list.configured(), |this| this.indicator()) + .when(nostr.read(cx).relay_list_state.configured(), |this| { + this.indicator() + }) .dropdown_menu(move |this, _window, cx| { let nostr = NostrRegistry::global(cx); let urls = nostr.read(cx).read_only_relays(&pkey, cx); diff --git a/crates/device/src/lib.rs b/crates/device/src/lib.rs index 375bb92..6a5aa5c 100644 --- a/crates/device/src/lib.rs +++ b/crates/device/src/lib.rs @@ -66,7 +66,7 @@ impl DeviceRegistry { subscriptions.push( // Observe the NIP-65 state cx.observe(&nostr, |this, state, cx| { - if state.read(cx).relay_list_state() == RelayState::Configured { + if state.read(cx).relay_list_state == RelayState::Configured { this.get_announcement(cx); }; }), @@ -477,7 +477,7 @@ impl DeviceRegistry { let write_relays = nostr.read(cx).write_relays(&public_key, cx); - let app_keys = nostr.read(cx).app_keys().clone(); + let app_keys = nostr.read(cx).app_keys.clone(); let app_pubkey = app_keys.public_key(); let task: Task, Error>> = cx.background_spawn(async move { @@ -549,7 +549,7 @@ impl DeviceRegistry { /// Parse the response event for device keys from other devices fn extract_encryption(&mut self, event: Event, cx: &mut Context) { let nostr = NostrRegistry::global(cx); - let app_keys = nostr.read(cx).app_keys().clone(); + let app_keys = nostr.read(cx).app_keys.clone(); let task: Task> = cx.background_spawn(async move { let root_device = event diff --git a/crates/state/src/constants.rs b/crates/state/src/constants.rs index 98555bd..fba1990 100644 --- a/crates/state/src/constants.rs +++ b/crates/state/src/constants.rs @@ -25,7 +25,7 @@ pub const FIND_LIMIT: usize = 20; pub const NOSTR_CONNECT_TIMEOUT: u64 = 200; /// Default Nostr Connect relay -pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app"; +pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com"; /// Default subscription id for device gift wrap events pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps"; diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index e718f93..359a718 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -5,7 +5,7 @@ use std::time::Duration; use anyhow::{anyhow, Context as AnyhowContext, Error}; use common::config_dir; -use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Task, Window}; use nostr_connect::prelude::*; use nostr_lmdb::prelude::*; use nostr_sdk::prelude::*; @@ -42,6 +42,16 @@ struct GlobalNostrRegistry(Entity); impl Global for GlobalNostrRegistry {} +/// Signer event. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum SignerEvent { + /// A new signer has been set + Set, + + /// An error occurred + Error(String), +} + /// Nostr Registry #[derive(Debug)] pub struct NostrRegistry { @@ -54,22 +64,22 @@ pub struct NostrRegistry { /// Local public keys npubs: Entity>, - /// App keys - /// - /// Used for Nostr Connect and NIP-4e operations - app_keys: Keys, - /// Custom gossip implementation gossip: Entity, + /// App keys + /// + /// Used for Nostr Connect and NIP-4e operations + pub app_keys: Keys, + /// Relay list state - relay_list_state: RelayState, + pub relay_list_state: RelayState, /// Whether Coop is connected to all bootstrap relays - connected: bool, + pub connected: bool, /// Whether Coop is creating a new signer - creating: bool, + pub creating: bool, /// Tasks for asynchronous operations tasks: Vec>>, @@ -146,29 +156,9 @@ impl NostrRegistry { self.signer.clone() } - /// Get the app keys - pub fn app_keys(&self) -> &Keys { - &self.app_keys - } - - /// Get the connected status of the client - pub fn connected(&self) -> bool { - self.connected - } - - /// Get the creating status - pub fn creating(&self) -> bool { - self.creating - } - - /// Get the relay list state - pub fn relay_list_state(&self) -> RelayState { - self.relay_list_state.clone() - } - - /// Get all relays for a given public key without ensuring connections - pub fn read_only_relays(&self, public_key: &PublicKey, cx: &App) -> Vec { - self.gossip.read(cx).read_only_relays(public_key) + /// Get the npubs entity + pub fn npubs(&self) -> Entity> { + self.npubs.clone() } /// Set the connected status of the client @@ -304,10 +294,11 @@ impl NostrRegistry { Ok(public_keys) => match public_keys.is_empty() { true => { this.update(cx, |this, cx| { - this.create_new_signer(cx); + this.create_identity(cx); })?; } false => { + // TODO: auto login npubs.update(cx, |this, cx| { this.extend(public_keys); cx.notify(); @@ -317,10 +308,11 @@ impl NostrRegistry { Err(e) => { log::error!("Failed to get npubs: {e}"); this.update(cx, |this, cx| { - this.create_new_signer(cx); + this.create_identity(cx); })?; } } + Ok(()) })); } @@ -332,7 +324,7 @@ impl NostrRegistry { } /// Create a new identity - pub fn create_new_signer(&mut self, cx: &mut Context) { + fn create_identity(&mut self, cx: &mut Context) { let client = self.client(); let keys = Keys::generate(); let async_keys = keys.clone(); @@ -411,16 +403,17 @@ impl NostrRegistry { })); } - // Get the signer in keyring by username + /// Get the signer in keyring by username pub fn get_signer( - &mut self, - username: &str, - cx: &mut Context, + &self, + public_key: &PublicKey, + cx: &App, ) -> Task, Error>> { - let app_keys = self.app_keys().clone(); - let read_credential = cx.read_credentials(username); + let username = public_key.to_bech32().unwrap(); + let app_keys = self.app_keys.clone(); + let read_credential = cx.read_credentials(&username); - cx.spawn(async move |_this, _cx| { + cx.spawn(async move |_cx| { let (_, secret) = read_credential .await .map_err(|_| anyhow!("Failed to get signer"))? @@ -439,7 +432,7 @@ impl NostrRegistry { let uri = NostrConnectUri::parse(&sec).map_err(|_| anyhow!("Failed to parse NIP-46 URI"))?; - let timeout = Duration::from_secs(120); + let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); let nip46 = NostrConnect::new(uri, app_keys, timeout, None)?; Ok(nip46.into_nostr_signer()) @@ -478,30 +471,37 @@ impl NostrRegistry { }); self.tasks.push(cx.spawn(async move |this, cx| { - // set signer - let public_key = task.await?; + match task.await { + Ok(public_key) => { + // Update states + this.update(cx, |this, cx| { + // Add public key to npubs if not already present + this.npubs.update(cx, |this, cx| { + if !this.contains(&public_key) { + this.push(public_key); + cx.notify(); + } + }); - // Update states - this.update(cx, |this, cx| { - this.npubs.update(cx, |this, cx| { - if !this.contains(&public_key) { - this.push(public_key); - cx.notify(); - } - }); - this.ensure_relay_list(cx); - })?; + // Ensure relay list for the user + this.ensure_relay_list(cx); + // Emit signer changed event + cx.emit(SignerEvent::Set); + })?; + } + Err(e) => { + this.update(cx, |_this, cx| { + cx.emit(SignerEvent::Error(e.to_string())); + })?; + } + } Ok(()) })); } /// Add a key signer to keyring - pub fn add_key_signer( - &mut self, - keys: &Keys, - cx: &mut Context, - ) -> Task> { + pub fn add_key_signer(&mut self, keys: &Keys, cx: &mut Context) { let keys = keys.clone(); let username = keys.public_key().to_bech32().unwrap(); let secret = keys.secret_key().to_secret_bytes(); @@ -509,25 +509,26 @@ impl NostrRegistry { // Write the credential to the keyring let write_credential = cx.write_credentials(&username, &username, &secret); - cx.spawn(async move |this, cx| { + self.tasks.push(cx.spawn(async move |this, cx| { match write_credential.await { Ok(_) => { this.update(cx, |this, cx| { this.set_signer(keys, cx); })?; } - Err(e) => return Err(anyhow!("Failed to write credential: {e}")), + Err(e) => { + this.update(cx, |_this, cx| { + cx.emit(SignerEvent::Error(e.to_string())); + })?; + } } + Ok(()) - }) + })); } /// Add a nostr connect signer to keyring - pub fn add_nip46_signer( - &mut self, - nip46: &NostrConnect, - cx: &mut Context, - ) -> Task> { + pub fn add_nip46_signer(&mut self, nip46: &NostrConnect, cx: &mut Context) { let nip46 = nip46.clone(); let async_nip46 = nip46.clone(); @@ -540,7 +541,7 @@ impl NostrRegistry { Ok((public_key, uri)) }); - cx.spawn(async move |this, cx| { + self.tasks.push(cx.spawn(async move |this, cx| { match task.await { Ok((public_key, uri)) => { let username = public_key.to_bech32().unwrap(); @@ -554,13 +555,22 @@ impl NostrRegistry { this.set_signer(nip46, cx); })?; } - Err(e) => return Err(anyhow!("Failed to write credential: {e}")), + Err(e) => { + this.update(cx, |_this, cx| { + cx.emit(SignerEvent::Error(e.to_string())); + })?; + } } } - Err(e) => return Err(anyhow!("Failed to connect to the remote signer: {e}")), + Err(e) => { + this.update(cx, |_this, cx| { + cx.emit(SignerEvent::Error(e.to_string())); + })?; + } } + Ok(()) - }) + })); } /// Set the state of the relay list @@ -716,9 +726,14 @@ impl NostrRegistry { }) } + /// Get all relays for a given public key without ensuring connections + pub fn read_only_relays(&self, public_key: &PublicKey, cx: &App) -> Vec { + self.gossip.read(cx).read_only_relays(public_key) + } + /// Generate a direct nostr connection initiated by the client pub fn nostr_connect(&self, relay: Option) -> (NostrConnect, NostrConnectUri) { - let app_keys = self.app_keys(); + let app_keys = self.app_keys.clone(); let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); // Determine the relay will be used for Nostr Connect @@ -894,6 +909,8 @@ impl NostrRegistry { } } +impl EventEmitter for NostrRegistry {} + /// Get or create a new app keys fn get_or_init_app_keys() -> Result { let dir = config_dir().join(".app_keys"); diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 5c6af86..8797aa6 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -34,6 +34,7 @@ pub enum IconName { CloseCircle, CloseCircleFill, Copy, + Device, Door, Ellipsis, Emoji, @@ -52,6 +53,7 @@ pub enum IconName { Relay, Reply, Refresh, + Scan, Search, Settings, Settings2, @@ -102,6 +104,7 @@ impl IconNamed for IconName { Self::CloseCircle => "icons/close-circle.svg", Self::CloseCircleFill => "icons/close-circle-fill.svg", Self::Copy => "icons/copy.svg", + Self::Device => "icons/device.svg", Self::Door => "icons/door.svg", Self::Ellipsis => "icons/ellipsis.svg", Self::Emoji => "icons/emoji.svg", @@ -120,6 +123,7 @@ impl IconNamed for IconName { Self::Relay => "icons/relay.svg", Self::Reply => "icons/reply.svg", Self::Refresh => "icons/refresh.svg", + Self::Scan => "icons/scan.svg", Self::Search => "icons/search.svg", Self::Settings => "icons/settings.svg", Self::Settings2 => "icons/settings2.svg", diff --git a/crates/ui/src/modal.rs b/crates/ui/src/modal.rs index 1e445c4..d30e9a2 100644 --- a/crates/ui/src/modal.rs +++ b/crates/ui/src/modal.rs @@ -343,7 +343,7 @@ impl RenderOnce for Modal { }); let window_paddings = crate::root::window_paddings(window, cx); - let radius = (cx.theme().radius_lg * 2.).min(px(20.)); + let radius = cx.theme().radius_lg; let view_size = window.viewport_size() - gpui::size( @@ -360,8 +360,8 @@ impl RenderOnce for Modal { let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top; let x = bounds.center().x - self.width / 2.; - let mut padding_right = px(16.); - let mut padding_left = px(16.); + let mut padding_right = px(8.); + let mut padding_left = px(8.); if let Some(pl) = self.style.padding.left { padding_left = pl.to_pixels(self.width.into(), window.rem_size());