diff --git a/Cargo.lock b/Cargo.lock index a5e8e7c..3ebe66f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1198,7 +1198,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1644,7 +1644,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "proc-macro2", "quote", @@ -2599,7 +2599,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "anyhow", "async-channel 2.5.0", @@ -2678,7 +2678,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2726,7 +2726,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "anyhow", "async-task", @@ -2768,7 +2768,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2779,7 +2779,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "console_error_panic_hook", "gpui", @@ -2792,7 +2792,7 @@ dependencies = [ [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "anyhow", "gpui", @@ -2803,7 +2803,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "anyhow", "log", @@ -2812,7 +2812,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "anyhow", "console_error_panic_hook", @@ -2835,7 +2835,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "anyhow", "bytemuck", @@ -2863,7 +2863,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "anyhow", "collections", @@ -3107,7 +3107,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "anyhow", "async-compression", @@ -3132,7 +3132,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "rustls", "rustls-platform-verifier", @@ -3668,12 +3668,13 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ "bitflags 2.11.0", "libc", + "plain", "redox_syscall 0.7.3", ] @@ -3893,7 +3894,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "anyhow", "bindgen", @@ -4120,7 +4121,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr" version = "0.44.1" -source = "git+https://github.com/rust-nostr/nostr#860e239ff894b8471b37c84dcac12b9572b8f064" +source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9" dependencies = [ "aes", "base64", @@ -4144,7 +4145,7 @@ dependencies = [ [[package]] name = "nostr-blossom" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#860e239ff894b8471b37c84dcac12b9572b8f064" +source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9" dependencies = [ "base64", "nostr", @@ -4155,7 +4156,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#860e239ff894b8471b37c84dcac12b9572b8f064" +source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9" dependencies = [ "async-utility", "futures-core", @@ -4168,7 +4169,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#860e239ff894b8471b37c84dcac12b9572b8f064" +source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9" dependencies = [ "btreecap", "flatbuffers", @@ -4178,7 +4179,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#860e239ff894b8471b37c84dcac12b9572b8f064" +source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9" dependencies = [ "nostr", ] @@ -4186,7 +4187,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.44.0" -source = "git+https://github.com/rust-nostr/nostr#860e239ff894b8471b37c84dcac12b9572b8f064" +source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9" dependencies = [ "async-utility", "flume", @@ -4200,7 +4201,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.44.1" -source = "git+https://github.com/rust-nostr/nostr#860e239ff894b8471b37c84dcac12b9572b8f064" +source = "git+https://github.com/rust-nostr/nostr#9bcc6cd779a7c6eb41509b37aee4575fa5ae47b9" dependencies = [ "async-utility", "async-wsocket", @@ -4637,7 +4638,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "collections", "serde", @@ -4770,6 +4771,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "png" version = "0.17.16" @@ -5318,7 +5325,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "derive_refineable", ] @@ -5417,7 +5424,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "anyhow", "bytes", @@ -5472,7 +5479,7 @@ dependencies = [ [[package]] name = "rope" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "arrayvec", "log", @@ -5734,7 +5741,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "async-task", "backtrace", @@ -6328,7 +6335,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "arrayvec", "log", @@ -7271,7 +7278,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "anyhow", "async-fs", @@ -7310,7 +7317,7 @@ dependencies = [ [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "perf", "quote", @@ -9113,7 +9120,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "anyhow", "chrono", @@ -9130,7 +9137,7 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" dependencies = [ "tracing", "tracing-subscriber", @@ -9141,7 +9148,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b06522e978b5ed24bcc2cf07a6de794179d69176" +source = "git+https://github.com/zed-industries/zed#4668aeb7284780cca830ba1173c4d3eb8bd11e2b" [[package]] name = "zune-core" 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/group.svg b/assets/icons/group.svg new file mode 100644 index 0000000..c150fe3 --- /dev/null +++ b/assets/icons/group.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 d9971c5..c599c61 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); } @@ -262,9 +262,12 @@ impl ChatRegistry { pub fn get_contact_list(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - let public_key = signer.public_key().unwrap(); + + let Some(public_key) = signer.public_key() else { + return; + }; + let write_relays = nostr.read(cx).write_relays(&public_key, cx); let task: Task> = cx.background_spawn(async move { @@ -318,9 +321,12 @@ impl ChatRegistry { fn verify_relays(&mut self, cx: &mut Context) -> Task> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - let public_key = signer.public_key().unwrap(); + + let Some(public_key) = signer.public_key() else { + return Task::ready(Err(anyhow!("User not found"))); + }; + let write_relays = nostr.read(cx).write_relays(&public_key, cx); cx.background_spawn(async move { @@ -685,14 +691,12 @@ impl ChatRegistry { } /// Trigger a refresh of the opened chat rooms by their IDs - pub fn refresh_rooms(&mut self, ids: Option>, cx: &mut Context) { - if let Some(ids) = ids { - for room in self.rooms.iter() { - if ids.contains(&room.read(cx).id) { - room.update(cx, |this, cx| { - this.emit_refresh(cx); - }); - } + pub fn refresh_rooms(&mut self, ids: &[u64], cx: &mut Context) { + for room in self.rooms.iter() { + if ids.contains(&room.read(cx).id) { + room.update(cx, |this, cx| { + this.emit_refresh(cx); + }); } } } diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 97e3a55..af0fdf4 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -331,7 +331,7 @@ impl Room { let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); - let sender = signer.public_key().unwrap(); + let sender = signer.public_key(); // Get room's id let id = self.id; @@ -340,7 +340,7 @@ impl Room { let members: Vec = self .members .iter() - .filter(|public_key| public_key != &&sender) + .filter(|public_key| Some(**public_key) != sender) .copied() .collect(); 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..b9fe557 --- /dev/null +++ b/crates/coop/src/dialogs/accounts.rs @@ -0,0 +1,256 @@ +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::indicator::Indicator; +use ui::{h_flex, v_flex, Disableable, 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 { + /// Public key currently being chosen for login + logging_in: Entity>, + + /// 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 logging_in = cx.new(|_| None); + 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.set_error(e.to_string(), cx); + } + }; + }); + + Self { + logging_in, + error, + tasks: vec![], + _subscription: Some(subscription), + } + } + + fn logging_in(&self, public_key: &PublicKey, cx: &App) -> bool { + self.logging_in.read(cx) == &Some(*public_key) + } + + fn set_logging_in(&mut self, public_key: PublicKey, cx: &mut Context) { + self.logging_in.update(cx, |this, cx| { + *this = Some(public_key); + cx.notify(); + }); + } + + fn set_error(&mut self, error: T, cx: &mut Context) + where + T: Into, + { + self.error.update(cx, |this, cx| { + *this = Some(error.into()); + cx.notify(); + }); + + self.logging_in.update(cx, |this, cx| { + *this = None; + cx.notify(); + }) + } + + 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); + + // Mark the public key as being logged in + self.set_logging_in(public_key, cx); + + 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) => { + this.update(cx, |this, cx| { + this.set_error(e.to_string(), cx); + })?; + } + }; + Ok(()) + })); + } + + fn remove(&mut self, public_key: PublicKey, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + + nostr.update(cx, |this, cx| { + this.remove_signer(&public_key, cx); + }); + } + + 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(); + let loading = self.logging_in.read(cx).is_some(); + + 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); + let logging_in = self.logging_in(public_key, cx); + + items.push( + h_flex() + .id(ix) + .group("") + .px_2() + .h_10() + .justify_between() + .w_full() + .rounded(cx.theme().radius) + .bg(cx.theme().ghost_element_background) + .hover(|this| this.bg(cx.theme().ghost_element_hover)) + .child( + h_flex() + .gap_2() + .child(Avatar::new(profile.avatar()).small()) + .child(div().text_sm().child(profile.name())), + ) + .when(logging_in, |this| this.child(Indicator::new().small())) + .when(!logging_in, |this| { + this.child( + h_flex() + .gap_1() + .invisible() + .group_hover("", |this| this.visible()) + .child( + Button::new(format!("del-{ix}")) + .icon(IconName::Close) + .ghost() + .small() + .disabled(logging_in) + .on_click(cx.listener({ + let public_key = *public_key; + move |this, _ev, _window, cx| { + cx.stop_propagation(); + this.remove(public_key, cx); + } + })), + ), + ) + }) + .when(!logging_in, |this| { + let public_key = *public_key; + this.on_click(cx.listener(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() + .disabled(loading) + .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() + .disabled(loading) + .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..0f861bd --- /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.add_key_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 5ad7e1c..aa47072 100644 --- a/crates/coop/src/panels/greeter.rs +++ b/crates/coop/src/panels/greeter.rs @@ -11,7 +11,7 @@ use ui::dock_area::dock::DockPlacement; use ui::dock_area::panel::{Panel, PanelEvent}; use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt}; -use crate::panels::{connect, import, messaging_relays, profile, relay_list}; +use crate::panels::{messaging_relays, profile, relay_list}; use crate::workspace::Workspace; pub fn init(window: &mut Window, cx: &mut App) -> Entity { @@ -86,10 +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 signer = nostr.read(cx).signer(); - let owned = signer.owned(); + let nip65 = nostr.read(cx).relay_list_state.clone(); let required_actions = nip65 == RelayState::NotConfigured || nip17 == InboxState::RelayNotAvailable; @@ -191,60 +188,6 @@ impl Render for GreeterPanel { ), ) }) - .when(!owned, |this| { - this.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| { - Workspace::add_panel( - connect::init(window, cx), - DockPlacement::Center, - 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| { - Workspace::add_panel( - import::init(window, cx), - DockPlacement::Center, - 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/panels/mod.rs b/crates/coop/src/panels/mod.rs index c1425d4..bb47e07 100644 --- a/crates/coop/src/panels/mod.rs +++ b/crates/coop/src/panels/mod.rs @@ -1,8 +1,6 @@ pub mod backup; -pub mod connect; pub mod contact_list; pub mod greeter; -pub mod import; pub mod messaging_relays; pub mod profile; pub mod relay_list; diff --git a/crates/coop/src/sidebar/mod.rs b/crates/coop/src/sidebar/mod.rs index be74034..a88da6c 100644 --- a/crates/coop/src/sidebar/mod.rs +++ b/crates/coop/src/sidebar/mod.rs @@ -16,7 +16,7 @@ use nostr_sdk::prelude::*; use person::PersonRegistry; use smallvec::{smallvec, SmallVec}; use state::{NostrRegistry, FIND_DELAY}; -use theme::{ActiveTheme, TABBAR_HEIGHT}; +use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT}; use ui::button::{Button, ButtonVariants}; use ui::dock_area::panel::{Panel, PanelEvent}; use ui::indicator::Indicator; @@ -585,10 +585,11 @@ impl Render for Sidebar { ) .when(!show_find_panel && !loading && total_rooms == 0, |this| { this.child( - div().px_2().child( + div().px_2().w(SIDEBAR_WIDTH).child( v_flex() .p_3() .h_24() + .w_full() .border_2() .border_dashed() .border_color(cx.theme().border_variant) diff --git a/crates/coop/src/workspace.rs b/crates/coop/src/workspace.rs index bf0e0f9..71cd1fe 100644 --- a/crates/coop/src/workspace.rs +++ b/crates/coop/src/workspace.rs @@ -11,7 +11,7 @@ use gpui::{ use person::PersonRegistry; use serde::Deserialize; use smallvec::{smallvec, SmallVec}; -use state::{NostrRegistry, RelayState}; +use state::{NostrRegistry, RelayState, SignerEvent}; use theme::{ActiveTheme, Theme, ThemeRegistry, SIDEBAR_WIDTH}; use title_bar::TitleBar; use ui::avatar::Avatar; @@ -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; @@ -42,6 +42,7 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { #[action(namespace = workspace, no_json)] enum Command { ToggleTheme, + ToggleAccount, RefreshEncryption, RefreshRelayList, @@ -64,11 +65,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 +85,24 @@ impl Workspace { }), ); + 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( + // Subscribe to the signer events + cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| { + if let SignerEvent::Set = event { + this.set_center_layout(window, cx); + } + }), + ); + subscriptions.push( // Observe all events emitted by the chat registry cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| { @@ -121,12 +142,12 @@ impl Workspace { let ids = this.panel_ids(cx); chat.update(cx, |this, cx| { - this.refresh_rooms(ids, cx); + this.refresh_rooms(&ids, cx); }); }), ); - // Set the default layout for app's dock + // Set the layout at the end of cycle cx.defer_in(window, |this, window, cx| { this.set_layout(window, cx); }); @@ -155,49 +176,40 @@ impl Workspace { } /// Get all panel ids - fn panel_ids(&self, cx: &App) -> Option> { - let ids: Vec = self - .dock + fn panel_ids(&self, cx: &App) -> Vec { + self.dock .read(cx) .items .panel_ids(cx) .into_iter() .filter_map(|panel| panel.parse::().ok()) - .collect(); - - Some(ids) + .collect() } /// Set the dock layout fn set_layout(&mut self, window: &mut Window, cx: &mut Context) { - let weak_dock = self.dock.downgrade(); - - // Sidebar let left = DockItem::panel(Arc::new(sidebar::init(window, cx))); - // Main workspace - let center = DockItem::split_with_sizes( - Axis::Vertical, - vec![DockItem::tabs( - vec![Arc::new(greeter::init(window, cx))], - None, - &weak_dock, - window, - cx, - )], - vec![None], - &weak_dock, - window, - cx, - ); - - // Update the dock layout + // Update the dock layout with sidebar on the left self.dock.update(cx, |this, cx| { this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx); + }); + } + + /// Set the center dock layout + fn set_center_layout(&mut self, window: &mut Window, cx: &mut Context) { + let dock = self.dock.downgrade(); + let greeeter = Arc::new(greeter::init(window, cx)); + let tabs = DockItem::tabs(vec![greeeter], None, &dock, window, cx); + let center = DockItem::split(Axis::Vertical, vec![tabs], &dock, window, cx); + + // Update the layout with center dock + self.dock.update(cx, |this, cx| { this.set_center(center, window, cx); }); } + /// Handle command events fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context) { match command { Command::ShowSettings => { @@ -206,7 +218,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()) }); @@ -290,6 +302,9 @@ impl Workspace { Command::ToggleTheme => { self.theme_selector(window, cx); } + Command::ToggleAccount => { + self.account_selector(window, cx); + } } } @@ -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() @@ -427,8 +458,15 @@ impl Workspace { h_flex() .flex_shrink_0() - .justify_between() .gap_2() + .when_none(¤t_user, |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from("Choose an account to continue...")), + ) + }) .when_some(current_user.as_ref(), |this, public_key| { let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get(public_key, cx); @@ -477,6 +515,11 @@ impl Workspace { Box::new(Command::ToggleTheme), ) .separator() + .menu_with_icon( + "Accounts", + IconName::Group, + Box::new(Command::ToggleAccount), + ) .menu_with_icon( "Settings", IconName::Settings, @@ -485,25 +528,11 @@ impl Workspace { }), ) }) - .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| { - this.child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Connecting...")), - ) - }) } 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 +662,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 +681,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 2d84fb5..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); }; }), @@ -204,9 +204,11 @@ impl DeviceRegistry { fn subscribe_to_giftwrap_events(&mut self, cx: &mut Context) -> Task> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - let public_key = signer.public_key().unwrap(); + + let Some(public_key) = signer.public_key() else { + return Task::ready(Err(anyhow!("User not found"))); + }; let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get(&public_key, cx); @@ -237,9 +239,11 @@ impl DeviceRegistry { pub fn get_announcement(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - let public_key = signer.public_key().unwrap(); + + let Some(public_key) = signer.public_key() else { + return; + }; // Reset state before fetching announcement self.reset(cx); @@ -303,10 +307,11 @@ impl DeviceRegistry { pub fn create_encryption(&self, cx: &App) -> Task> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - - // Get current user let signer = nostr.read(cx).signer(); - let public_key = signer.public_key().unwrap(); + + let Some(public_key) = signer.public_key() else { + return Task::ready(Err(anyhow!("User not found"))); + }; // Get user's write relays let write_relays = nostr.read(cx).write_relays(&public_key, cx); @@ -398,9 +403,11 @@ impl DeviceRegistry { pub fn listen_request(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - let public_key = signer.public_key().unwrap(); + + let Some(public_key) = signer.public_key() else { + return; + }; let write_relays = nostr.read(cx).write_relays(&public_key, cx); @@ -430,9 +437,11 @@ impl DeviceRegistry { fn listen_approval(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - let public_key = signer.public_key().unwrap(); + + let Some(public_key) = signer.public_key() else { + return; + }; let write_relays = nostr.read(cx).write_relays(&public_key, cx); @@ -460,13 +469,15 @@ impl DeviceRegistry { fn request(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - let public_key = signer.public_key().unwrap(); + + let Some(public_key) = signer.public_key() else { + return; + }; 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 { @@ -538,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 @@ -573,10 +584,11 @@ impl DeviceRegistry { fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - - // Get current user let signer = nostr.read(cx).signer(); - let public_key = signer.public_key().unwrap(); + + let Some(public_key) = signer.public_key() else { + return; + }; // Get user's write relays let write_relays = nostr.read(cx).write_relays(&public_key, cx); diff --git a/crates/state/src/constants.rs b/crates/state/src/constants.rs index 98555bd..9df12f3 100644 --- a/crates/state/src/constants.rs +++ b/crates/state/src/constants.rs @@ -21,18 +21,18 @@ pub const FIND_DELAY: u64 = 600; /// Default limit for searching pub const FIND_LIMIT: usize = 20; -/// Default timeout for Nostr Connect -pub const NOSTR_CONNECT_TIMEOUT: u64 = 200; - -/// Default Nostr Connect relay -pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app"; - /// Default subscription id for device gift wrap events pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps"; /// Default subscription id for user gift wrap events pub const USER_GIFTWRAP: &str = "user-gift-wraps"; +/// Default timeout for Nostr Connect +pub const NOSTR_CONNECT_TIMEOUT: u64 = 60; + +/// Default Nostr Connect relay +pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nip46.com"; + /// Default vertex relays pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"]; @@ -40,10 +40,9 @@ pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"]; pub const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"]; /// Default bootstrap relays -pub const BOOTSTRAP_RELAYS: [&str; 4] = [ - "wss://nos.lol", - "wss://relay.damus.io", +pub const BOOTSTRAP_RELAYS: [&str; 3] = [ "wss://relay.primal.net", + "wss://indexer.coracle.social", "wss://user.kindpag.es", ]; diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index c05fa72..ff42195 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::*; @@ -51,22 +51,19 @@ pub struct NostrRegistry { /// Nostr signer signer: Arc, - /// App keys - /// - /// Used for Nostr Connect and NIP-4e operations - app_keys: Keys, + /// Local public keys + npubs: Entity>, /// 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, - - /// Whether Coop is connected to all bootstrap relays - connected: bool, - - /// Whether Coop is creating a new signer - creating: bool, + pub relay_list_state: RelayState, /// Tasks for asynchronous operations tasks: Vec>>, @@ -89,6 +86,9 @@ impl NostrRegistry { let app_keys = get_or_init_app_keys().unwrap_or(Keys::generate()); let signer = Arc::new(CoopSigner::new(app_keys.clone())); + // Construct the nostr npubs entity + let npubs = cx.new(|_| vec![]); + // Construct the gossip entity let gossip = cx.new(|_| Gossip::default()); @@ -120,15 +120,30 @@ impl NostrRegistry { Self { client, signer, + npubs, app_keys, gossip, relay_list_state: RelayState::Idle, - connected: false, - creating: false, tasks: vec![], } } + /// Get the nostr client + pub fn client(&self) -> Client { + self.client.clone() + } + + /// Get the nostr signer + pub fn signer(&self) -> Arc { + self.signer.clone() + } + + /// Get the npubs entity + pub fn npubs(&self) -> Entity> { + self.npubs.clone() + } + + /// Connect to the bootstrapping relays fn connect(&mut self, cx: &mut Context) { let client = self.client(); @@ -146,14 +161,13 @@ impl NostrRegistry { } // Connect to all added relays - client.connect().and_wait(Duration::from_secs(5)).await; + client.connect().and_wait(Duration::from_secs(2)).await; }) .await; // Update the state this.update(cx, |this, cx| { - this.set_connected(cx); - this.get_signer(cx); + this.get_npubs(cx); })?; Ok(()) @@ -214,39 +228,406 @@ impl NostrRegistry { })); } - /// Get the nostr client - pub fn client(&self) -> Client { - self.client.clone() + /// Get all used npubs + fn get_npubs(&mut self, cx: &mut Context) { + let npubs = self.npubs.downgrade(); + let task: Task, Error>> = cx.background_spawn(async move { + let dir = config_dir().join("keys"); + // Ensure keys directory exists + smol::fs::create_dir_all(&dir).await?; + + let mut files = smol::fs::read_dir(&dir).await?; + let mut entries = Vec::new(); + + while let Some(Ok(entry)) = files.next().await { + let metadata = entry.metadata().await?; + let modified_time = metadata.modified()?; + let name = entry + .file_name() + .into_string() + .unwrap() + .replace(".npub", ""); + + entries.push((modified_time, name)); + } + + // Sort by modification time (most recent first) + entries.sort_by(|a, b| b.0.cmp(&a.0)); + + let mut npubs = Vec::new(); + + for (_, name) in entries { + let public_key = PublicKey::parse(&name)?; + npubs.push(public_key); + } + + Ok(npubs) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + match task.await { + Ok(public_keys) => match public_keys.is_empty() { + true => { + this.update(cx, |this, cx| { + this.create_identity(cx); + })?; + } + false => { + // TODO: auto login + npubs.update(cx, |this, cx| { + this.extend(public_keys); + cx.notify(); + })?; + } + }, + Err(e) => { + log::error!("Failed to get npubs: {e}"); + this.update(cx, |this, cx| { + this.create_identity(cx); + })?; + } + } + + Ok(()) + })); } - /// Get the nostr signer - pub fn signer(&self) -> Arc { - self.signer.clone() + /// Create a new identity + fn create_identity(&mut self, cx: &mut Context) { + let client = self.client(); + let keys = Keys::generate(); + let async_keys = keys.clone(); + + let username = keys.public_key().to_bech32().unwrap(); + let secret = keys.secret_key().to_secret_bytes(); + + // Create a write credential task + let write_credential = cx.write_credentials(&username, &username, &secret); + + // Run async tasks in background + let task: Task> = cx.background_spawn(async move { + let signer = async_keys.into_nostr_signer(); + + // Get default relay list + let relay_list = default_relay_list(); + + // Publish relay list event + let event = EventBuilder::relay_list(relay_list).sign(&signer).await?; + client + .send_event(&event) + .ok_timeout(Duration::from_secs(TIMEOUT)) + .await?; + + // Construct the default metadata + let name = petname::petname(2, "-").unwrap_or("Cooper".to_string()); + let avatar = Url::parse(&format!("https://avatar.vercel.sh/{name}")).unwrap(); + let metadata = Metadata::new().display_name(&name).picture(avatar); + + // Publish metadata event + let event = EventBuilder::metadata(&metadata).sign(&signer).await?; + client + .send_event(&event) + .ack_policy(AckPolicy::none()) + .await?; + + // Construct the default contact list + let contacts = vec![Contact::new(PublicKey::parse(COOP_PUBKEY).unwrap())]; + + // Publish contact list event + let event = EventBuilder::contact_list(contacts).sign(&signer).await?; + client + .send_event(&event) + .ack_policy(AckPolicy::none()) + .await?; + + // Construct the default messaging relay list + let relays = default_messaging_relays(); + + // Publish messaging relay list event + let event = EventBuilder::nip17_relay_list(relays).sign(&signer).await?; + client + .send_event(&event) + .ack_policy(AckPolicy::none()) + .await?; + + // Write user's credentials to the system keyring + write_credential.await?; + + Ok(()) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + // Wait for the task to complete + task.await?; + + // Set signer + this.update(cx, |this, cx| { + this.set_signer(keys, cx); + })?; + + Ok(()) + })); } - /// Get the app keys - pub fn app_keys(&self) -> &Keys { - &self.app_keys + /// Get the signer in keyring by username + pub fn get_signer( + &self, + public_key: &PublicKey, + cx: &App, + ) -> Task, Error>> { + 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 |_cx| { + let (_, secret) = read_credential + .await + .map_err(|_| anyhow!("Failed to get signer. Please re-import the secret key"))? + .ok_or_else(|| anyhow!("Failed to get signer. Please re-import the secret key"))?; + + // Try to parse as a direct secret key first + if let Ok(secret_key) = SecretKey::from_slice(&secret) { + return Ok(Keys::new(secret_key).into_nostr_signer()); + } + + // Convert the secret into string + let sec = String::from_utf8(secret) + .map_err(|_| anyhow!("Failed to parse secret as UTF-8"))?; + + // Try to parse as a NIP-46 URI + let uri = + NostrConnectUri::parse(&sec).map_err(|_| anyhow!("Failed to parse NIP-46 URI"))?; + + let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); + let mut nip46 = NostrConnect::new(uri, app_keys, timeout, None)?; + + // Set the auth URL handler + nip46.auth_url_handler(CoopAuthUrlHandler); + + Ok(nip46.into_nostr_signer()) + }) } - /// Get the connected status of the client - pub fn connected(&self) -> bool { - self.connected + /// Set the signer for the nostr client and verify the public key + pub fn set_signer(&mut self, new: T, cx: &mut Context) + where + T: NostrSigner + 'static, + { + let client = self.client(); + let signer = self.signer(); + + // Create a task to update the signer and verify the public key + let task: Task> = cx.background_spawn(async move { + // Update signer and unsubscribe + signer.switch(new).await; + client.unsubscribe_all().await?; + + // Verify and get public key + let signer = client.signer().context("Signer not found")?; + let public_key = signer.get_public_key().await?; + + let npub = public_key.to_bech32().unwrap(); + let keys_dir = config_dir().join("keys"); + + // Ensure keys directory exists + smol::fs::create_dir_all(&keys_dir).await?; + + let key_path = keys_dir.join(format!("{}.npub", npub)); + smol::fs::write(key_path, "").await?; + + log::info!("Signer's public key: {}", public_key); + Ok(public_key) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + 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(); + } + }); + + // 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(()) + })); } - /// Get the creating status - pub fn creating(&self) -> bool { - self.creating + /// Remove a signer from the keyring + pub fn remove_signer(&mut self, public_key: &PublicKey, cx: &mut Context) { + let public_key = public_key.to_owned(); + let npub = public_key.to_bech32().unwrap(); + let keys_dir = config_dir().join("keys"); + + self.tasks.push(cx.spawn(async move |this, cx| { + let key_path = keys_dir.join(format!("{}.npub", npub)); + smol::fs::remove_file(key_path).await?; + + this.update(cx, |this, cx| { + this.npubs().update(cx, |this, cx| { + this.retain(|k| k != &public_key); + cx.notify(); + }); + })?; + + Ok(()) + })); } - /// Get the relay list state - pub fn relay_list_state(&self) -> RelayState { - self.relay_list_state.clone() + /// Add a key signer to keyring + 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(); + + // Write the credential to the keyring + let write_credential = cx.write_credentials(&username, "keys", &secret); + + 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) => { + this.update(cx, |_this, cx| { + cx.emit(SignerEvent::Error(e.to_string())); + })?; + } + } + + Ok(()) + })); } - /// 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) + /// Add a nostr connect signer to keyring + pub fn add_nip46_signer(&mut self, nip46: &NostrConnect, cx: &mut Context) { + let nip46 = nip46.clone(); + let async_nip46 = nip46.clone(); + + // Connect and verify the remote signer + let task: Task> = + cx.background_spawn(async move { + let uri = async_nip46.bunker_uri().await?; + let public_key = async_nip46.get_public_key().await?; + + Ok((public_key, uri)) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + match task.await { + Ok((public_key, uri)) => { + let username = public_key.to_bech32().unwrap(); + let write_credential = this.read_with(cx, |_this, cx| { + cx.write_credentials(&username, "nostrconnect", uri.to_string().as_bytes()) + })?; + + match write_credential.await { + Ok(_) => { + this.update(cx, |this, cx| { + this.set_signer(nip46, cx); + })?; + } + Err(e) => { + this.update(cx, |_this, cx| { + cx.emit(SignerEvent::Error(e.to_string())); + })?; + } + } + } + Err(e) => { + this.update(cx, |_this, cx| { + cx.emit(SignerEvent::Error(e.to_string())); + })?; + } + } + + Ok(()) + })); + } + + /// Set the state of the relay list + fn set_relay_state(&mut self, state: RelayState, cx: &mut Context) { + self.relay_list_state = state; + cx.notify(); + } + + pub fn ensure_relay_list(&mut self, cx: &mut Context) { + let task = self.verify_relay_list(cx); + + // Set the state to idle before starting the task + self.set_relay_state(RelayState::default(), cx); + + self.tasks.push(cx.spawn(async move |this, cx| { + let result = task.await?; + + // Update state + this.update(cx, |this, cx| { + this.relay_list_state = result; + cx.notify(); + })?; + + Ok(()) + })); + } + + // Verify relay list for current user + fn verify_relay_list(&mut self, cx: &mut Context) -> Task> { + let client = self.client(); + + cx.background_spawn(async move { + let signer = client.signer().context("Signer not found")?; + let public_key = signer.get_public_key().await?; + + let filter = Filter::new() + .kind(Kind::RelayList) + .author(public_key) + .limit(1); + + // Construct target for subscription + let target: HashMap<&str, Vec> = BOOTSTRAP_RELAYS + .into_iter() + .map(|relay| (relay, vec![filter.clone()])) + .collect(); + + // Stream events from the bootstrap relays + let mut stream = client + .stream_events(target) + .timeout(Duration::from_secs(TIMEOUT)) + .await?; + + while let Some((_url, res)) = stream.next().await { + match res { + Ok(event) => { + log::info!("Received relay list event: {event:?}"); + return Ok(RelayState::Configured); + } + Err(e) => { + log::error!("Failed to receive relay list event: {e}"); + } + } + } + + Ok(RelayState::NotConfigured) + }) } /// Ensure write relays for a given public key @@ -336,316 +717,9 @@ impl NostrRegistry { }) } - /// Set the connected status of the client - fn set_connected(&mut self, cx: &mut Context) { - self.connected = true; - cx.notify(); - } - - /// Get local stored signer - fn get_signer(&mut self, cx: &mut Context) { - let read_credential = cx.read_credentials(KEYRING); - - self.tasks.push(cx.spawn(async move |this, cx| { - match read_credential.await { - Ok(Some((_user, secret))) => { - let secret = SecretKey::from_slice(&secret)?; - let keys = Keys::new(secret); - - this.update(cx, |this, cx| { - this.set_signer(keys, false, cx); - })?; - } - _ => { - this.update(cx, |this, cx| { - this.get_bunker(cx); - })?; - } - } - - Ok(()) - })); - } - - /// Get local stored bunker connection - fn get_bunker(&mut self, cx: &mut Context) { - let client = self.client(); - let app_keys = self.app_keys().clone(); - let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); - - let task: Task> = cx.background_spawn(async move { - log::info!("Getting bunker connection"); - - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .identifier("coop:account") - .limit(1); - - if let Some(event) = client.database().query(filter).await?.first_owned() { - let uri = NostrConnectUri::parse(event.content)?; - let signer = NostrConnect::new(uri.clone(), app_keys.clone(), timeout, None)?; - - Ok(signer) - } else { - Err(anyhow!("No account found")) - } - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - match task.await { - Ok(signer) => { - this.update(cx, |this, cx| { - this.set_signer(signer, true, cx); - }) - .ok(); - } - Err(e) => { - log::warn!("Failed to get bunker: {e}"); - // Create a new identity if no stored bunker exists - this.update(cx, |this, cx| { - this.set_default_signer(cx); - }) - .ok(); - } - } - - Ok(()) - })); - } - - /// Set the signer for the nostr client and verify the public key - pub fn set_signer(&mut self, new: T, owned: bool, cx: &mut Context) - where - T: NostrSigner + 'static, - { - let client = self.client(); - let signer = self.signer(); - - // Create a task to update the signer and verify the public key - let task: Task> = cx.background_spawn(async move { - // Update signer - signer.switch(new, owned).await; - - // Unsubscribe from all subscriptions - client.unsubscribe_all().await?; - - // Verify signer - let signer = client.signer().context("Signer not found")?; - let public_key = signer.get_public_key().await?; - log::info!("Signer's public key: {}", public_key); - - Ok(()) - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - // set signer - task.await?; - - // Update states - this.update(cx, |this, cx| { - this.ensure_relay_list(cx); - })?; - - Ok(()) - })); - } - - /// Create a new identity - fn set_default_signer(&mut self, cx: &mut Context) { - let client = self.client(); - let keys = Keys::generate(); - let async_keys = keys.clone(); - - // Create a write credential task - let write_credential = cx.write_credentials( - KEYRING, - &keys.public_key().to_hex(), - &keys.secret_key().to_secret_bytes(), - ); - - // Set the creating signer status - self.set_creating_signer(true, cx); - - // Run async tasks in background - let task: Task> = cx.background_spawn(async move { - let signer = async_keys.into_nostr_signer(); - - // Get default relay list - let relay_list = default_relay_list(); - - // Publish relay list event - let event = EventBuilder::relay_list(relay_list).sign(&signer).await?; - client - .send_event(&event) - .ok_timeout(Duration::from_secs(TIMEOUT)) - .await?; - - // Construct the default metadata - let name = petname::petname(2, "-").unwrap_or("Cooper".to_string()); - let avatar = Url::parse(&format!("https://avatar.vercel.sh/{name}")).unwrap(); - let metadata = Metadata::new().display_name(&name).picture(avatar); - - // Publish metadata event - let event = EventBuilder::metadata(&metadata).sign(&signer).await?; - client - .send_event(&event) - .ok_timeout(Duration::from_secs(TIMEOUT)) - .ack_policy(AckPolicy::none()) - .await?; - - // Construct the default contact list - let contacts = vec![Contact::new(PublicKey::parse(COOP_PUBKEY).unwrap())]; - - // Publish contact list event - let event = EventBuilder::contact_list(contacts).sign(&signer).await?; - client - .send_event(&event) - .ok_timeout(Duration::from_secs(TIMEOUT)) - .ack_policy(AckPolicy::none()) - .await?; - - // Construct the default messaging relay list - let relays = default_messaging_relays(); - - // Publish messaging relay list event - let event = EventBuilder::nip17_relay_list(relays).sign(&signer).await?; - client - .send_event(&event) - .ok_timeout(Duration::from_secs(TIMEOUT)) - .ack_policy(AckPolicy::none()) - .await?; - - // Write user's credentials to the system keyring - write_credential.await?; - - Ok(()) - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - // Wait for the task to complete - task.await?; - - this.update(cx, |this, cx| { - this.set_creating_signer(false, cx); - this.set_signer(keys, false, cx); - })?; - - Ok(()) - })); - } - - /// Set whether Coop is creating a new signer - fn set_creating_signer(&mut self, creating: bool, cx: &mut Context) { - self.creating = creating; - cx.notify(); - } - - /// Set the state of the relay list - fn set_relay_state(&mut self, state: RelayState, cx: &mut Context) { - self.relay_list_state = state; - cx.notify(); - } - - pub fn ensure_relay_list(&mut self, cx: &mut Context) { - let task = self.verify_relay_list(cx); - - // Set the state to idle before starting the task - self.set_relay_state(RelayState::default(), cx); - - self.tasks.push(cx.spawn(async move |this, cx| { - let result = task.await?; - - // Update state - this.update(cx, |this, cx| { - this.relay_list_state = result; - cx.notify(); - })?; - - Ok(()) - })); - } - - // Verify relay list for current user - fn verify_relay_list(&mut self, cx: &mut Context) -> Task> { - let client = self.client(); - - cx.background_spawn(async move { - let signer = client.signer().context("Signer not found")?; - let public_key = signer.get_public_key().await?; - - let filter = Filter::new() - .kind(Kind::RelayList) - .author(public_key) - .limit(1); - - // Construct target for subscription - let target: HashMap<&str, Vec> = BOOTSTRAP_RELAYS - .into_iter() - .map(|relay| (relay, vec![filter.clone()])) - .collect(); - - // Stream events from the bootstrap relays - let mut stream = client - .stream_events(target) - .timeout(Duration::from_secs(TIMEOUT)) - .await?; - - while let Some((_url, res)) = stream.next().await { - match res { - Ok(event) => { - log::info!("Received relay list event: {event:?}"); - return Ok(RelayState::Configured); - } - Err(e) => { - log::error!("Failed to receive relay list event: {e}"); - } - } - } - - Ok(RelayState::NotConfigured) - }) - } - - /// Generate a direct nostr connection initiated by the client - pub fn client_connect(&self, relay: Option) -> (NostrConnect, NostrConnectUri) { - let app_keys = self.app_keys(); - let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); - - // Determine the relay will be used for Nostr Connect - let relay = match relay { - Some(relay) => relay, - None => 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); - - (signer, uri) - } - - /// Store the bunker connection for the next login - pub fn persist_bunker(&mut self, uri: NostrConnectUri, cx: &mut App) { - let client = self.client(); - let rng_keys = Keys::generate(); - - self.tasks.push(cx.background_spawn(async move { - // Construct the event for application-specific data - let event = EventBuilder::new(Kind::ApplicationSpecificData, uri.to_string()) - .tag(Tag::identifier("coop:account")) - .sign(&rng_keys) - .await?; - - // Store the event in the database - client.database().save_event(&event).await?; - - Ok(()) - })); + /// 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 public key of a NIP-05 address @@ -803,6 +877,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"); @@ -912,6 +988,16 @@ fn default_messaging_relays() -> Vec { ] } +/// Signer event. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum SignerEvent { + /// A new signer has been set + Set, + + /// An error occurred + Error(String), +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum RelayState { #[default] diff --git a/crates/state/src/signer.rs b/crates/state/src/signer.rs index 4cb1691..262e611 100644 --- a/crates/state/src/signer.rs +++ b/crates/state/src/signer.rs @@ -1,6 +1,5 @@ use std::borrow::Cow; use std::result::Result; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use nostr_sdk::prelude::*; @@ -16,11 +15,6 @@ pub struct CoopSigner { /// Specific signer for encryption purposes encryption_signer: RwLock>>, - - /// By default, Coop generates a new signer for new users. - /// - /// This flag indicates whether the signer is user-owned or Coop-generated. - owned: AtomicBool, } impl CoopSigner { @@ -32,7 +26,6 @@ impl CoopSigner { signer: RwLock::new(signer.into_nostr_signer()), signer_pkey: RwLock::new(None), encryption_signer: RwLock::new(None), - owned: AtomicBool::new(false), } } @@ -47,17 +40,15 @@ impl CoopSigner { } /// Get public key + /// + /// Ensure to call this method after the signer has been initialized. + /// Otherwise, this method will panic. pub fn public_key(&self) -> Option { - self.signer_pkey.read_blocking().to_owned() - } - - /// Get the flag indicating whether the signer is user-owned. - pub fn owned(&self) -> bool { - self.owned.load(Ordering::SeqCst) + *self.signer_pkey.read_blocking() } /// Switch the current signer to a new signer. - pub async fn switch(&self, new: T, owned: bool) + pub async fn switch(&self, new: T) where T: IntoNostrSigner, { @@ -75,9 +66,6 @@ impl CoopSigner { // Reset the encryption signer *encryption_signer = None; - - // Update the owned flag - self.owned.store(owned, Ordering::SeqCst); } /// Set the encryption signer. diff --git a/crates/ui/src/dock_area/mod.rs b/crates/ui/src/dock_area/mod.rs index 2312740..395983e 100644 --- a/crates/ui/src/dock_area/mod.rs +++ b/crates/ui/src/dock_area/mod.rs @@ -2,10 +2,11 @@ use std::sync::Arc; use gpui::prelude::FluentBuilder; use gpui::{ - actions, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, Entity, - EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, ParentElement as _, - Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, + actions, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Decorations, + Edges, Entity, EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, + ParentElement as _, Pixels, Render, SharedString, Styled, Subscription, WeakEntity, Window, }; +use theme::CLIENT_SIDE_DECORATION_ROUNDING; use crate::dock_area::dock::{Dock, DockPlacement}; use crate::dock_area::panel::{Panel, PanelEvent, PanelStyle, PanelView}; @@ -202,19 +203,16 @@ impl DockItem { /// Returns all panel ids pub fn panel_ids(&self, cx: &App) -> Vec { match self { - Self::Tabs { view, .. } => view.read(cx).panel_ids(cx), - Self::Split { items, .. } => { - let mut total = vec![]; - - for item in items.iter() { - if let DockItem::Tabs { view, .. } = item { - total.extend(view.read(cx).panel_ids(cx)); - } - } - - total - } Self::Panel { .. } => vec![], + Self::Tabs { view, .. } => view.read(cx).panel_ids(cx), + Self::Split { items, .. } => items + .iter() + .filter_map(|item| match item { + DockItem::Tabs { view, .. } => Some(view.read(cx).panel_ids(cx)), + _ => None, + }) + .flatten() + .collect(), } } @@ -745,6 +743,7 @@ impl EventEmitter for DockArea {} impl Render for DockArea { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let view = cx.entity().clone(); + let decorations = window.window_decorations(); div() .id("dock-area") @@ -754,7 +753,17 @@ impl Render for DockArea { .on_prepaint(move |bounds, _, cx| view.update(cx, |r, _| r.bounds = bounds)) .map(|this| { if let Some(zoom_view) = self.zoom_view.clone() { - this.child(zoom_view) + this.map(|this| match decorations { + Decorations::Server => this, + Decorations::Client { tiling } => this + .when(!(tiling.top || tiling.right), |div| { + div.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING) + }) + .when(!(tiling.top || tiling.left), |div| { + div.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING) + }), + }) + .child(zoom_view) } else { // render dock this.child( diff --git a/crates/ui/src/dock_area/tab_panel.rs b/crates/ui/src/dock_area/tab_panel.rs index 6e412fa..252e8e5 100644 --- a/crates/ui/src/dock_area/tab_panel.rs +++ b/crates/ui/src/dock_area/tab_panel.rs @@ -1080,8 +1080,10 @@ impl TabPanel { window: &mut Window, cx: &mut Context, ) { - if let Some(panel) = self.active_panel(cx) { - self.remove_panel(&panel, window, cx); + if self.panels.len() > 1 { + if let Some(panel) = self.active_panel(cx) { + self.remove_panel(&panel, window, cx); + } } } } diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 5c6af86..2e293a4 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,12 +53,14 @@ pub enum IconName { Relay, Reply, Refresh, + Scan, Search, Settings, Settings2, Sun, Ship, Shield, + Group, UserKey, Upload, Usb, @@ -102,6 +105,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 +124,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", @@ -129,6 +134,7 @@ impl IconNamed for IconName { Self::UserKey => "icons/user-key.svg", Self::Upload => "icons/upload.svg", Self::Usb => "icons/usb.svg", + Self::Group => "icons/group.svg", Self::PanelLeft => "icons/panel-left.svg", Self::PanelLeftOpen => "icons/panel-left-open.svg", Self::PanelRight => "icons/panel-right.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()); diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 0eab87e..b3ae977 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -249,7 +249,6 @@ impl Render for Root { div() .id("window") .size_full() - .bg(gpui::transparent_black()) .map(|div| match decorations { Decorations::Server => div, Decorations::Client { tiling } => div