From 440f17af182118688ed61094227d7b6649c4dd00 Mon Sep 17 00:00:00 2001 From: reya <123083837+reyamir@users.noreply.github.com> Date: Tue, 17 Jun 2025 07:16:16 +0700 Subject: [PATCH] refactor: Client keys and Identity (#61) * . * . * . * . * refactor client keys * . * . * refactor * . * . * . * update new account --- Cargo.lock | 70 ++-- Cargo.toml | 1 - crates/chats/Cargo.toml | 1 + crates/chats/src/lib.rs | 7 +- crates/chats/src/room.rs | 13 +- crates/client_keys/Cargo.toml | 14 + crates/client_keys/src/lib.rs | 121 ++++++ crates/common/Cargo.toml | 2 + crates/common/src/handle_auth.rs | 13 + crates/common/src/lib.rs | 1 + crates/coop/Cargo.toml | 3 +- crates/coop/src/chatspace.rs | 93 ++++- crates/coop/src/main.rs | 65 +-- crates/coop/src/views/chat.rs | 9 +- crates/coop/src/views/compose.rs | 2 +- crates/coop/src/views/login.rs | 445 +++++++++++--------- crates/coop/src/views/new_account.rs | 68 ++- crates/coop/src/views/onboarding.rs | 227 ++++++++-- crates/coop/src/views/preferences.rs | 12 +- crates/coop/src/views/profile.rs | 2 +- crates/coop/src/views/sidebar/element.rs | 2 +- crates/coop/src/views/sidebar/mod.rs | 12 +- crates/coop/src/views/startup.rs | 9 +- crates/global/Cargo.toml | 1 - crates/global/src/constants.rs | 32 +- crates/global/src/lib.rs | 199 +++------ crates/identity/Cargo.toml | 20 + crates/identity/src/lib.rs | 511 +++++++++++++++++++++++ crates/settings/src/lib.rs | 27 +- crates/ui/src/checkbox.rs | 30 +- crates/ui/src/modal.rs | 34 +- 31 files changed, 1460 insertions(+), 586 deletions(-) create mode 100644 crates/client_keys/Cargo.toml create mode 100644 crates/client_keys/src/lib.rs create mode 100644 crates/common/src/handle_auth.rs create mode 100644 crates/identity/Cargo.toml create mode 100644 crates/identity/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c315be4..cf2ca6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -925,6 +925,7 @@ dependencies = [ "fuzzy-matcher", "global", "gpui", + "identity", "itertools 0.13.0", "log", "nostr", @@ -971,6 +972,18 @@ dependencies = [ "libloading", ] +[[package]] +name = "client_keys" +version = "0.1.5" +dependencies = [ + "anyhow", + "global", + "gpui", + "log", + "nostr-sdk", + "smallvec", +] + [[package]] name = "cmake" version = "0.1.54" @@ -1096,10 +1109,12 @@ dependencies = [ "global", "gpui", "itertools 0.13.0", + "nostr-connect", "nostr-sdk", "qrcode-generator", "smallvec", "smol", + "webbrowser", ] [[package]] @@ -1144,11 +1159,13 @@ dependencies = [ "anyhow", "auto_update", "chats", + "client_keys", "common", "dirs 5.0.1", "futures", "global", "gpui", + "identity", "itertools 0.13.0", "log", "nostr-connect", @@ -1164,7 +1181,6 @@ dependencies = [ "theme", "tracing-subscriber", "ui", - "webbrowser", ] [[package]] @@ -2189,7 +2205,6 @@ dependencies = [ "futures", "log", "nostr-connect", - "nostr-keyring", "nostr-sdk", "rustls", "smol", @@ -2802,6 +2817,24 @@ dependencies = [ "zerovec", ] +[[package]] +name = "identity" +version = "0.1.5" +dependencies = [ + "anyhow", + "client_keys", + "common", + "global", + "gpui", + "log", + "nostr-connect", + "nostr-sdk", + "oneshot", + "settings", + "smallvec", + "ui", +] + [[package]] name = "idna" version = "1.0.3" @@ -3049,20 +3082,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "keyring" -version = "3.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1961983669d57bdfe6c0f3ef8e4c229b5ef751afcc7d87e4271d2f71f6ccfa8b" -dependencies = [ - "byteorder", - "linux-keyutils", - "log", - "security-framework 2.11.1", - "security-framework 3.2.0", - "windows-sys 0.59.0", -] - [[package]] name = "khronos-egl" version = "6.0.0" @@ -3170,16 +3189,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "linux-keyutils" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e" -dependencies = [ - "bitflags 2.9.1", - "libc", -] - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3586,15 +3595,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "nostr-keyring" -version = "0.42.0" -source = "git+https://github.com/rust-nostr/nostr#4096b9da00f18c3089f734b27ce1388616f3cb13" -dependencies = [ - "keyring", - "nostr", -] - [[package]] name = "nostr-lmdb" version = "0.42.0" diff --git a/Cargo.toml b/Cargo.toml index d235919..15493cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,6 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" } nostr = { git = "https://github.com/rust-nostr/nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = ["lmdb", "nip96", "nip59", "nip49", "nip44", "nip05"] } nostr-connect = { git = "https://github.com/rust-nostr/nostr" } -nostr-keyring = { git = "https://github.com/rust-nostr/nostr" } # Others emojis = "0.6.4" diff --git a/crates/chats/Cargo.toml b/crates/chats/Cargo.toml index 10ced3b..f70ef63 100644 --- a/crates/chats/Cargo.toml +++ b/crates/chats/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] common = { path = "../common" } global = { path = "../global" } +identity = { path = "../identity" } settings = { path = "../settings" } gpui.workspace = true diff --git a/crates/chats/src/lib.rs b/crates/chats/src/lib.rs index 787f388..1553bed 100644 --- a/crates/chats/src/lib.rs +++ b/crates/chats/src/lib.rs @@ -9,6 +9,7 @@ use global::shared_state; use gpui::{ App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window, }; +use identity::Identity; use itertools::Itertools; use nostr_sdk::prelude::*; use room::RoomKind; @@ -77,7 +78,7 @@ impl ChatRegistry { let mut subscriptions = smallvec![]; // When the ChatRegistry is created, load all rooms from the local database - subscriptions.push(cx.observe_new::(|this, window, cx| { + subscriptions.push(cx.observe_new::(|this, window, cx| { if let Some(window) = window { this.load_rooms(window, cx); } @@ -162,7 +163,7 @@ impl ChatRegistry { /// 4. Creates Room entities for each unique room pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context) { let client = &shared_state().client; - let Some(public_key) = shared_state().identity().map(|i| i.public_key()) else { + let Some(public_key) = Identity::get_global(cx).profile().map(|i| i.public_key()) else { return; }; @@ -288,7 +289,7 @@ impl ChatRegistry { pub fn event_to_message(&mut self, event: Event, window: &mut Window, cx: &mut Context) { let id = room_hash(&event); let author = event.pubkey; - let Some(public_key) = shared_state().identity().map(|i| i.public_key()) else { + let Some(public_key) = Identity::get_global(cx).profile().map(|i| i.public_key()) else { return; }; diff --git a/crates/chats/src/room.rs b/crates/chats/src/room.rs index 7c2993c..24e650e 100644 --- a/crates/chats/src/room.rs +++ b/crates/chats/src/room.rs @@ -7,6 +7,7 @@ use common::profile::RenderProfile; use common::{compare, room_hash}; use global::shared_state; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window}; +use identity::Identity; use itertools::Itertools; use nostr_sdk::prelude::*; use settings::AppSettings; @@ -165,8 +166,8 @@ impl Room { /// # Returns /// /// The Profile of the first member in the room - pub fn first_member(&self, _cx: &App) -> Profile { - let Some(account) = shared_state().identity() else { + pub fn first_member(&self, cx: &App) -> Profile { + let Some(account) = Identity::get_global(cx).profile() else { return shared_state().person(&self.members[0]); }; @@ -250,7 +251,7 @@ impl Room { /// - For a direct message: the other person's avatar /// - For a group chat: None pub fn display_image(&self, cx: &App) -> SharedString { - let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars; + let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; if let Some(picture) = self.picture.as_ref() { picture.clone() @@ -550,9 +551,9 @@ impl Room { &self, content: &str, replies: Option<&Vec>, - _cx: &App, + cx: &App, ) -> Option { - let author = shared_state().identity()?; + let author = Identity::get_global(cx).profile()?; let public_key = author.public_key(); let builder = EventBuilder::private_msg_rumor(public_key, content); @@ -633,7 +634,7 @@ impl Room { let subject = self.subject.clone(); let picture = self.picture.clone(); let public_keys = Arc::clone(&self.members); - let backup = AppSettings::get_global(cx).settings().backup_messages; + let backup = AppSettings::get_global(cx).settings.backup_messages; cx.background_spawn(async move { let signer = shared_state().client.signer().await?; diff --git a/crates/client_keys/Cargo.toml b/crates/client_keys/Cargo.toml new file mode 100644 index 0000000..8d6f50e --- /dev/null +++ b/crates/client_keys/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "client_keys" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +global = { path = "../global" } + +nostr-sdk.workspace = true +gpui.workspace = true +anyhow.workspace = true +log.workspace = true +smallvec.workspace = true diff --git a/crates/client_keys/src/lib.rs b/crates/client_keys/src/lib.rs new file mode 100644 index 0000000..8bee67a --- /dev/null +++ b/crates/client_keys/src/lib.rs @@ -0,0 +1,121 @@ +use global::{constants::KEYRING_URL, shared_state}; +use gpui::{App, AppContext, Context, Entity, Global, Subscription, Window}; +use nostr_sdk::prelude::*; +use smallvec::{smallvec, SmallVec}; + +pub fn init(cx: &mut App) { + ClientKeys::set_global(cx.new(ClientKeys::new), cx); +} + +struct GlobalClientKeys(Entity); + +impl Global for GlobalClientKeys {} + +pub struct ClientKeys { + keys: Option, + #[allow(dead_code)] + subscriptions: SmallVec<[Subscription; 1]>, +} + +impl ClientKeys { + /// Retrieve the Global Client Keys instance + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + /// Retrieve the Client Keys instance + pub fn get_global(cx: &App) -> &Self { + cx.global::().0.read(cx) + } + + /// Set the Global Client Keys instance + pub(crate) fn set_global(state: Entity, cx: &mut App) { + cx.set_global(GlobalClientKeys(state)); + } + + pub(crate) fn new(cx: &mut Context) -> Self { + let mut subscriptions = smallvec![]; + + subscriptions.push(cx.observe_new::(|this, window, cx| { + if let Some(window) = window { + this.load(window, cx); + } + })); + + Self { + keys: None, + subscriptions, + } + } + + pub fn load(&mut self, window: &mut Window, cx: &mut Context) { + let read_client_keys = cx.read_credentials(KEYRING_URL); + + cx.spawn_in(window, async move |this, cx| { + if let Ok(Some((_, secret))) = read_client_keys.await { + // Update keys + this.update(cx, |this, cx| { + let Ok(secret_key) = SecretKey::from_slice(&secret) else { + this.set_keys(None, false, cx); + return; + }; + let keys = Keys::new(secret_key); + this.set_keys(Some(keys), false, cx); + }) + .ok(); + } else if shared_state().first_run { + // Generate a new keys and update + this.update(cx, |this, cx| { + this.new_keys(cx); + }) + .ok(); + } else { + this.update(cx, |this, cx| { + this.set_keys(None, false, cx); + }) + .ok(); + } + }) + .detach(); + } + + pub(crate) fn set_keys(&mut self, keys: Option, persist: bool, cx: &mut Context) { + if let Some(keys) = keys.clone() { + if persist { + let write_keys = cx.write_credentials( + KEYRING_URL, + keys.public_key().to_hex().as_str(), + keys.secret_key().as_secret_bytes(), + ); + + cx.background_spawn(async move { + if let Err(e) = write_keys.await { + log::error!("Failed to save the client keys: {e}") + } + }) + .detach(); + + cx.notify(); + } + } + + self.keys = keys; + // Make sure notify the UI after keys changes + cx.notify(); + } + + pub fn new_keys(&mut self, cx: &mut Context) { + self.set_keys(Some(Keys::generate()), true, cx); + } + + pub fn keys(&self) -> Keys { + self.keys + .as_ref() + .cloned() + .expect("Keys should always be initialized") + } + + pub fn has_keys(&self) -> bool { + self.keys.is_some() + } +} diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 87387e1..6ddaf54 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -8,6 +8,7 @@ publish.workspace = true global = { path = "../global" } gpui.workspace = true +nostr-connect.workspace = true nostr-sdk.workspace = true anyhow.workspace = true itertools.workspace = true @@ -16,4 +17,5 @@ smallvec.workspace = true smol.workspace = true futures.workspace = true +webbrowser = "1.0.4" qrcode-generator = "5.0.0" diff --git a/crates/common/src/handle_auth.rs b/crates/common/src/handle_auth.rs new file mode 100644 index 0000000..fe2185a --- /dev/null +++ b/crates/common/src/handle_auth.rs @@ -0,0 +1,13 @@ +use nostr_connect::prelude::*; + +#[derive(Debug, Clone)] +pub struct CoopAuthUrlHandler; + +impl AuthUrlHandler for CoopAuthUrlHandler { + fn on_auth_url(&self, auth_url: Url) -> BoxedFuture> { + Box::pin(async move { + webbrowser::open(auth_url.as_str())?; + Ok(()) + }) + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index b50c833..3a1c9cf 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -8,6 +8,7 @@ use nostr_sdk::prelude::*; use qrcode_generator::QrCodeEcc; pub mod debounced_delay; +pub mod handle_auth; pub mod profile; pub async fn nip96_upload( diff --git a/crates/coop/Cargo.toml b/crates/coop/Cargo.toml index ac6bf2b..9ea758d 100644 --- a/crates/coop/Cargo.toml +++ b/crates/coop/Cargo.toml @@ -10,11 +10,13 @@ path = "src/main.rs" [dependencies] ui = { path = "../ui" } +identity = { path = "../identity" } theme = { path = "../theme" } common = { path = "../common" } global = { path = "../global" } chats = { path = "../chats" } settings = { path = "../settings" } +client_keys = { path = "../client_keys" } auto_update = { path = "../auto_update" } gpui.workspace = true @@ -35,5 +37,4 @@ smol.workspace = true futures.workspace = true oneshot.workspace = true -webbrowser = "1.0.4" tracing-subscriber = { version = "0.3.18", features = ["fmt"] } diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs index 2cd4f8e..8abd6c9 100644 --- a/crates/coop/src/chatspace.rs +++ b/crates/coop/src/chatspace.rs @@ -2,13 +2,15 @@ use std::sync::Arc; use anyhow::Error; use chats::{ChatRegistry, RoomEmitter}; +use client_keys::ClientKeys; use global::constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH}; use global::shared_state; use gpui::prelude::FluentBuilder; use gpui::{ - div, impl_internal_actions, px, App, AppContext, Axis, Context, Entity, IntoElement, + div, impl_internal_actions, px, relative, App, AppContext, Axis, Context, Entity, IntoElement, ParentElement, Render, Styled, Subscription, Task, Window, }; +use identity::Identity; use nostr_connect::prelude::*; use serde::Deserialize; use smallvec::{smallvec, SmallVec}; @@ -17,7 +19,8 @@ use ui::button::{Button, ButtonVariants}; use ui::dock_area::dock::DockPlacement; use ui::dock_area::panel::PanelView; use ui::dock_area::{DockArea, DockItem}; -use ui::{ContextModal, IconName, Root, Sizable, TitleBar}; +use ui::modal::ModalButtonProps; +use ui::{ContextModal, IconName, Root, Sizable, StyledExt, TitleBar}; use crate::views::chat::{self, Chat}; use crate::views::{login, new_account, onboarding, preferences, sidebar, startup, welcome}; @@ -62,7 +65,7 @@ pub struct ChatSpace { dock: Entity, titlebar: bool, #[allow(unused)] - subscriptions: SmallVec<[Subscription; 2]>, + subscriptions: SmallVec<[Subscription; 4]>, } impl ChatSpace { @@ -78,8 +81,81 @@ impl ChatSpace { cx.new(|cx| { let chats = ChatRegistry::global(cx); + let client_keys = ClientKeys::global(cx); + let identity = Identity::global(cx); let mut subscriptions = smallvec![]; + // Observe the client keys and show an alert modal if they fail to initialize + subscriptions.push(cx.observe_in( + &client_keys, + window, + |_this: &mut Self, state, window, cx| { + if !state.read(cx).has_keys() { + window.open_modal(cx, |this, _window, cx| { + const DESCRIPTION: &str = + "Allow Coop to read the client keys stored in Keychain to continue"; + + this.overlay_closable(false) + .show_close(false) + .keyboard(false) + .confirm() + .button_props( + ModalButtonProps::default() + .cancel_text("Create New Keys") + .ok_text("Allow"), + ) + .child( + div() + .px_10() + .w_full() + .h_40() + .flex() + .flex_col() + .gap_1() + .items_center() + .justify_center() + .text_center() + .text_sm() + .child( + div() + .font_semibold() + .text_color(cx.theme().text_muted) + .child("Warning"), + ) + .child(div().line_height(relative(1.4)).child(DESCRIPTION)), + ) + .on_cancel(|_, _window, cx| { + ClientKeys::global(cx).update(cx, |this, cx| { + this.new_keys(cx); + }); + // true: Close modal + true + }) + .on_ok(|_, window, cx| { + ClientKeys::global(cx).update(cx, |this, cx| { + this.load(window, cx); + }); + // true: Close modal + true + }) + }); + } + }, + )); + + // Observe the identity and show onboarding if it fails to initialize + subscriptions.push(cx.observe_in( + &identity, + window, + |this: &mut Self, state, window, cx| { + if !state.read(cx).has_profile() { + this.open_onboarding(window, cx); + } else { + this.open_chats(window, cx); + } + }, + )); + // Automatically load messages when chat panel opens subscriptions.push(cx.observe_new::(|this: &mut Chat, window, cx| { if let Some(window) = window { @@ -91,7 +167,7 @@ impl ChatSpace { subscriptions.push(cx.subscribe_in( &chats, window, - |this: &mut ChatSpace, _state, event, window, cx| { + |this: &mut Self, _state, event, window, cx| { if let RoomEmitter::Open(room) = event { if let Some(room) = room.upgrade() { this.dock.update(cx, |this, cx| { @@ -223,11 +299,10 @@ impl ChatSpace { } } - fn logout(&self, _window: &mut Window, cx: &mut App) { - cx.background_spawn(async move { - shared_state().unset_signer().await; - }) - .detach(); + fn logout(&self, window: &mut Window, cx: &mut App) { + Identity::global(cx).update(cx, |this, cx| { + this.unload(window, cx); + }); } pub(crate) fn set_center_panel(panel: P, window: &mut Window, cx: &mut App) { diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index ccb9626..05ffda8 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -1,13 +1,11 @@ use std::sync::Arc; -use std::time::Duration; -use anyhow::Error; use asset::Assets; use auto_update::AutoUpdater; use chats::ChatRegistry; +use global::constants::APP_ID; #[cfg(not(target_os = "linux"))] use global::constants::APP_NAME; -use global::constants::{APP_ID, KEYRING_BUNKER, KEYRING_USER_PATH}; use global::{shared_state, NostrSignal}; use gpui::{ actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, @@ -17,7 +15,6 @@ use gpui::{ use gpui::{point, SharedString, TitlebarOptions}; #[cfg(target_os = "linux")] use gpui::{WindowBackgroundAppearance, WindowDecorations}; -use nostr_connect::prelude::*; use theme::Theme; use ui::Root; @@ -94,6 +91,10 @@ fn main() { ui::init(cx); // Initialize settings settings::init(cx); + // Initialize client keys + client_keys::init(cx); + // Initialize identity + identity::init(window, cx); // Initialize auto update auto_update::init(cx); // Initialize chat state @@ -102,42 +103,6 @@ fn main() { // Initialize chatspace (or workspace) let chatspace = chatspace::init(window, cx); let async_chatspace = chatspace.downgrade(); - let async_chatspace_clone = async_chatspace.clone(); - - // Read user's credential - let read_credential = cx.read_credentials(KEYRING_USER_PATH); - - cx.spawn_in(window, async move |_, cx| { - if let Ok(Some((user, secret))) = read_credential.await { - cx.update(|window, cx| { - if let Ok(signer) = extract_credential(&user, secret) { - cx.background_spawn(async move { - if let Err(e) = shared_state().set_signer(signer).await { - log::error!("Signer error: {}", e); - } - }) - .detach(); - } else { - async_chatspace - .update(cx, |this, cx| { - this.open_onboarding(window, cx); - }) - .ok(); - } - }) - .ok(); - } else { - cx.update(|window, cx| { - async_chatspace - .update(cx, |this, cx| { - this.open_onboarding(window, cx); - }) - .ok(); - }) - .ok(); - } - }) - .detach(); // Spawn a task to handle events from nostr channel cx.spawn_in(window, async move |_, cx| { @@ -148,14 +113,14 @@ fn main() { match signal { NostrSignal::SignerUpdated => { - async_chatspace_clone + async_chatspace .update(cx, |this, cx| { this.open_chats(window, cx); }) .ok(); } NostrSignal::SignerUnset => { - async_chatspace_clone + async_chatspace .update(cx, |this, cx| { this.open_onboarding(window, cx); }) @@ -190,22 +155,6 @@ fn main() { }); } -fn extract_credential(user: &str, secret: Vec) -> Result { - if user == KEYRING_BUNKER { - let value = String::from_utf8(secret)?; - let uri = NostrConnectURI::parse(value)?; - let client_keys = shared_state().client_signer.clone(); - let signer = NostrConnect::new(uri, client_keys, Duration::from_secs(300), None)?; - - Ok(signer.into_nostr_signer()) - } else { - let secret_key = SecretKey::from_slice(&secret)?; - let keys = Keys::new(secret_key); - - Ok(keys.into_nostr_signer()) - } -} - fn quit(_: &Quit, cx: &mut App) { log::info!("Gracefully quitting the application . . ."); cx.quit(); diff --git a/crates/coop/src/views/chat.rs b/crates/coop/src/views/chat.rs index addbbb9..a6f7b61 100644 --- a/crates/coop/src/views/chat.rs +++ b/crates/coop/src/views/chat.rs @@ -17,6 +17,7 @@ use gpui::{ ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, Window, }; +use identity::Identity; use itertools::Itertools; use nostr_sdk::prelude::*; use serde::Deserialize; @@ -220,7 +221,7 @@ impl Chat { // TODO: find a better way to prevent duplicate messages during optimistic updates fn prevent_duplicate_message(&self, new_msg: &Message, cx: &Context) -> bool { - let Some(account) = shared_state().identity() else { + let Some(account) = Identity::get_global(cx).profile() else { return false; }; @@ -372,7 +373,7 @@ impl Chat { self.uploading(true, cx); - let nip96 = AppSettings::get_global(cx).settings().media_server.clone(); + let nip96 = AppSettings::get_global(cx).settings.media_server.clone(); let paths = cx.prompt_for_paths(PathPromptOptions { files: true, directories: false, @@ -546,8 +547,8 @@ impl Chat { return div().id(ix); }; - let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars; - let hide_avatar = AppSettings::get_global(cx).settings().hide_user_avatars; + let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; + let hide_avatar = AppSettings::get_global(cx).settings.hide_user_avatars; let message = message.borrow(); diff --git a/crates/coop/src/views/compose.rs b/crates/coop/src/views/compose.rs index 8a81d93..cd69feb 100644 --- a/crates/coop/src/views/compose.rs +++ b/crates/coop/src/views/compose.rs @@ -306,7 +306,7 @@ impl Render for Compose { const DESCRIPTION: &str = "Start a conversation with someone using their npub or NIP-05 (like foo@bar.com)."; - let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars; + let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; let label: SharedString = if self.selected.read(cx).len() > 1 { "Create Group DM".into() diff --git a/crates/coop/src/views/login.rs b/crates/coop/src/views/login.rs index 4aee0b1..42b6646 100644 --- a/crates/coop/src/views/login.rs +++ b/crates/coop/src/views/login.rs @@ -1,15 +1,17 @@ use std::sync::Arc; use std::time::Duration; +use client_keys::ClientKeys; +use common::handle_auth::CoopAuthUrlHandler; use common::string_to_qr; -use global::constants::{APP_NAME, KEYRING_BUNKER, KEYRING_USER_PATH}; -use global::shared_state; +use global::constants::{APP_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT}; use gpui::prelude::FluentBuilder; use gpui::{ div, img, red, relative, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, }; +use identity::Identity; use nostr_connect::prelude::*; use smallvec::{smallvec, SmallVec}; use theme::ActiveTheme; @@ -20,21 +22,6 @@ use ui::notification::Notification; use ui::popup_menu::PopupMenu; use ui::{ContextModal, Disableable, Sizable, StyledExt}; -const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app"; -const NOSTR_CONNECT_TIMEOUT: u64 = 300; - -#[derive(Debug, Clone)] -struct CoopAuthUrlHandler; - -impl AuthUrlHandler for CoopAuthUrlHandler { - fn on_auth_url(&self, auth_url: Url) -> BoxedFuture> { - Box::pin(async move { - webbrowser::open(auth_url.as_str())?; - Ok(()) - }) - } -} - pub fn init(window: &mut Window, cx: &mut App) -> Entity { Login::new(window, cx) } @@ -72,9 +59,9 @@ impl Login { // NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md // // Direct connection initiated by the client - let connection_string = cx.new(|_cx| { + let connection_string = cx.new(|cx| { let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap(); - let client_keys = shared_state().client_signer.clone(); + let client_keys = ClientKeys::get_global(cx).keys(); NostrConnectURI::client(client_keys.public_key(), vec![relay], APP_NAME) }); @@ -90,6 +77,7 @@ impl Login { let error = cx.new(|_| None); let mut subscriptions = smallvec![]; + // Subscribe to key input events and process login when the user presses enter subscriptions.push( cx.subscribe_in(&key_input, window, |this, _, event, window, cx| { if let InputEvent::PressEnter { .. } = event { @@ -98,6 +86,7 @@ impl Login { }), ); + // Subscribe to relay input events and change relay when the user presses enter subscriptions.push( cx.subscribe_in(&relay_input, window, |this, _, event, window, cx| { if let InputEvent::PressEnter { .. } = event { @@ -106,41 +95,38 @@ impl Login { }), ); - subscriptions.push(cx.observe_new::( - move |connection_string, _window, cx| { - if let Ok(mut signer) = NostrConnect::new( - connection_string.to_owned(), - shared_state().client_signer.clone(), - Duration::from_secs(NOSTR_CONNECT_TIMEOUT), - None, - ) { - // Automatically open remote signer's webpage when received auth url - signer.auth_url_handler(CoopAuthUrlHandler); + // Observe the Connect URI that changes when the relay is changed + subscriptions.push(cx.observe_new::(move |uri, _window, cx| { + let client_keys = ClientKeys::get_global(cx).keys(); + let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); - async_active_signer - .update(cx, |this, cx| { - *this = Some(signer); - cx.notify(); - }) - .ok(); - } + if let Ok(mut signer) = NostrConnect::new(uri.to_owned(), client_keys, timeout, None) { + // Automatically open auth url + signer.auth_url_handler(CoopAuthUrlHandler); - // Update the QR Image with the new connection string - async_qr_image + async_active_signer .update(cx, |this, cx| { - *this = string_to_qr(&connection_string.to_string()); + *this = Some(signer); cx.notify(); }) .ok(); - }, - )); + } + + // Update the QR Image with the new connection string + async_qr_image + .update(cx, |this, cx| { + *this = string_to_qr(&uri.to_string()); + cx.notify(); + }) + .ok(); + })); subscriptions.push(cx.observe_in( &connection_string, window, |this, entity, _window, cx| { let connection_string = entity.read(cx).clone(); - let client_keys = shared_state().client_signer.clone(); + let client_keys = ClientKeys::get_global(cx).keys(); // Update the QR Image with the new connection string this.qr_image.update(cx, |this, cx| { @@ -148,58 +134,35 @@ impl Login { cx.notify(); }); - if let Ok(mut signer) = NostrConnect::new( + match NostrConnect::new( connection_string, client_keys, Duration::from_secs(NOSTR_CONNECT_TIMEOUT), None, ) { - // Automatically open remote signer's webpage when received auth url - signer.auth_url_handler(CoopAuthUrlHandler); + Ok(mut signer) => { + // Automatically open auth url + signer.auth_url_handler(CoopAuthUrlHandler); - this.active_signer.update(cx, |this, cx| { - *this = Some(signer); - cx.notify(); - }); + this.active_signer.update(cx, |this, cx| { + *this = Some(signer); + cx.notify(); + }); + } + Err(_) => { + log::error!("Failed to create Nostr Connect") + } } }, )); subscriptions.push( - cx.observe_in(&active_signer, window, |_this, entity, window, cx| { - if let Some(signer) = entity.read(cx).clone() { - let (tx, rx) = oneshot::channel::>(); - - cx.background_spawn(async move { - if let Ok(bunker_uri) = signer.bunker_uri().await { - tx.send(Some(bunker_uri)).ok(); - - if let Err(e) = shared_state().set_signer(signer).await { - log::error!("{}", e); - } - } else { - tx.send(None).ok(); - } - }) - .detach(); - - cx.spawn_in(window, async move |this, cx| { - if let Ok(Some(uri)) = rx.await { - this.update(cx, |this, cx| { - this.save_bunker(&uri, cx); - }) - .ok(); - } else { - cx.update(|window, cx| { - window.push_notification( - Notification::error("Connection failed"), - cx, - ); - }) - .ok(); - } - }) - .detach(); + cx.observe_in(&active_signer, window, |this, entity, window, cx| { + if let Some(mut signer) = entity.read(cx).clone() { + // Automatically open auth url + signer.auth_url_handler(CoopAuthUrlHandler); + // Wait for connection from remote signer + this.wait_for_connection(signer, window, cx); } }), ); @@ -222,90 +185,212 @@ impl Login { if self.is_logging_in { return; }; + // Prevent duplicate login requests self.set_logging_in(true, cx); - let client_keys = shared_state().client_signer.clone(); - let content = self.key_input.read(cx).value(); - - if content.starts_with("nsec1") { - let Ok(keys) = SecretKey::parse(content.as_ref()).map(Keys::new) else { - self.set_error("Secret key is not valid", cx); - return; - }; - - // Active signer is no longer needed - self.shutdown_active_signer(cx); - - // Save these keys to the OS storage for further logins - self.save_keys(&keys, cx); - - // Set signer with this keys in the background - cx.background_spawn(async move { - if let Err(e) = shared_state().set_signer(keys).await { - log::error!("{}", e); - } - }) - .detach(); - } else if content.starts_with("bunker://") { - let Ok(uri) = NostrConnectURI::parse(content.as_ref()) else { - self.set_error("Bunker URL is not valid", cx); - return; - }; - - // Active signer is no longer needed - self.shutdown_active_signer(cx); - - match NostrConnect::new( - uri.clone(), - client_keys, - Duration::from_secs(NOSTR_CONNECT_TIMEOUT / 2), - None, - ) { - Ok(signer) => { - let (tx, rx) = oneshot::channel::>(); - - // Set signer with this remote signer in the background - cx.background_spawn(async move { - if let Ok(bunker_uri) = signer.bunker_uri().await { - tx.send(Some(bunker_uri)).ok(); - - if let Err(e) = shared_state().set_signer(signer).await { - log::error!("{}", e); - } - } else { - tx.send(None).ok(); - } - }) - .detach(); - - // Handle error - cx.spawn_in(window, async move |this, cx| { - if let Ok(Some(uri)) = rx.await { - this.update(cx, |this, cx| { - this.save_bunker(&uri, cx); - }) - .ok(); - } else { - this.update(cx, |this, cx| { - this.set_error( - "Connection to the Remote Signer failed or timed out", - cx, - ); - }) - .ok(); - } - }) - .detach(); - } - Err(e) => { - self.set_error(e.to_string(), cx); - } - } - } else { - self.set_error("You must provide a valid Private Key or Bunker.", cx); + // Content can be secret key or bunker:// + match self.key_input.read(cx).value().to_string() { + s if s.starts_with("nsec1") => self.ask_for_password(s, window, cx), + s if s.starts_with("ncryptsec1") => self.ask_for_password(s, window, cx), + s if s.starts_with("bunker://") => self.login_with_bunker(&s, window, cx), + _ => self.set_error("You must provide a valid Private Key or Bunker.", cx), }; } + fn ask_for_password(&mut self, content: String, window: &mut Window, cx: &mut Context) { + let current_view = cx.entity().downgrade(); + let pwd_input = cx.new(|cx| InputState::new(window, cx).masked(true)); + let weak_input = pwd_input.downgrade(); + + window.open_modal(cx, move |this, _window, cx| { + let weak_input = weak_input.clone(); + let view_cancel = current_view.clone(); + let view_ok = current_view.clone(); + + let label: SharedString = if content.starts_with("nsec1") { + "Set password to encrypt your key *".into() + } else { + "Password to decrypt your key *".into() + }; + + let description: Option = if content.starts_with("ncryptsec1") { + Some("Coop will only stored the encrypted version".into()) + } else { + None + }; + + this.overlay_closable(false) + .show_close(false) + .keyboard(false) + .confirm() + .on_cancel(move |_, _window, cx| { + view_cancel + .update(cx, |this, cx| { + this.set_error("Password is required", cx); + }) + .ok(); + true + }) + .on_ok(move |_, window, cx| { + let value = weak_input + .read_with(cx, |state, _cx| state.value().to_owned()) + .ok(); + + view_ok + .update(cx, |this, cx| { + if let Some(password) = value { + this.login_with_keys(password.to_string(), window, cx); + } else { + this.set_error("Password is required", cx); + } + }) + .ok(); + true + }) + .child( + div() + .pt_4() + .px_4() + .w_full() + .flex() + .flex_col() + .gap_1() + .text_sm() + .child(label) + .child(TextInput::new(&pwd_input).small()) + .when_some(description, |this, description| { + this.child( + div() + .text_xs() + .italic() + .text_color(cx.theme().text_placeholder) + .child(description), + ) + }), + ) + }); + } + + fn login_with_keys(&mut self, password: String, window: &mut Window, cx: &mut Context) { + let value = self.key_input.read(cx).value().to_string(); + let secret_key = if value.starts_with("nsec1") { + SecretKey::parse(&value).ok() + } else if value.starts_with("ncryptsec1") { + EncryptedSecretKey::from_bech32(&value) + .map(|enc| enc.decrypt(&password).ok()) + .unwrap_or_default() + } else { + None + }; + + if let Some(secret_key) = secret_key { + // Active signer is no longer needed + self.shutdown_active_signer(cx); + + let keys = Keys::new(secret_key); + + Identity::global(cx).update(cx, |this, cx| { + this.write_keys(&keys, password, cx); + this.set_signer(keys, window, cx); + }); + } else { + self.set_error("Secret Key is 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 URL is not valid", cx); + return; + }; + + let client_keys = ClientKeys::get_global(cx).keys(); + let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT / 2); + + let Ok(mut signer) = NostrConnect::new(uri, client_keys, timeout, None) else { + self.set_error("Failed to create remote signer", cx); + return; + }; + + // Active signer is no longer needed + self.shutdown_active_signer(cx); + + // Automatically open auth url + signer.auth_url_handler(CoopAuthUrlHandler); + + let (tx, rx) = oneshot::channel::>(); + + // Verify remote signer connection + cx.background_spawn(async move { + if let Ok(bunker_uri) = signer.bunker_uri().await { + tx.send(Some((signer, bunker_uri))).ok(); + } else { + tx.send(None).ok(); + } + }) + .detach(); + + cx.spawn_in(window, async move |this, cx| { + if let Ok(Some((signer, uri))) = rx.await { + cx.update(|window, cx| { + Identity::global(cx).update(cx, |this, cx| { + this.write_bunker(&uri, cx); + this.set_signer(signer, window, cx); + }); + }) + .ok(); + } else { + this.update(cx, |this, cx| { + let msg = "Connection to the Remote Signer failed or timed out"; + this.set_error(msg, cx); + }) + .ok(); + } + }) + .detach(); + } + + fn wait_for_connection( + &mut self, + signer: NostrConnect, + window: &mut Window, + cx: &mut Context, + ) { + let (tx, rx) = oneshot::channel::>(); + + cx.background_spawn(async move { + if let Ok(bunker_uri) = signer.bunker_uri().await { + tx.send(Some((bunker_uri, signer))).ok(); + } else { + tx.send(None).ok(); + } + }) + .detach(); + + cx.spawn_in(window, async move |this, cx| { + if let Ok(Some((uri, signer))) = rx.await { + cx.update(|window, cx| { + Identity::global(cx).update(cx, |this, cx| { + this.write_bunker(&uri, cx); + this.set_signer(signer, window, cx); + }); + }) + .ok(); + } else { + cx.update(|window, cx| { + window.push_notification(Notification::error("Connection failed"), cx); + // Refresh the active signer + this.update(cx, |this, cx| { + this.change_relay(window, cx); + }) + .ok(); + }) + .ok(); + } + }) + .detach(); + } + fn change_relay(&mut self, window: &mut Window, cx: &mut Context) { let Ok(relay_url) = RelayUrl::parse(self.relay_input.read(cx).value().to_string().as_str()) else { @@ -313,7 +398,7 @@ impl Login { return; }; - let client_keys = shared_state().client_signer.clone(); + let client_keys = ClientKeys::get_global(cx).keys(); let uri = NostrConnectURI::client(client_keys.public_key(), vec![relay_url], "Coop"); self.connection_string.update(cx, |this, cx| { @@ -322,40 +407,6 @@ impl Login { }); } - fn save_keys(&self, keys: &Keys, cx: &mut Context) { - let save_credential = cx.write_credentials( - KEYRING_USER_PATH, - keys.public_key().to_hex().as_str(), - keys.secret_key().as_secret_bytes(), - ); - - cx.background_spawn(async move { - if let Err(e) = save_credential.await { - log::error!("Failed to save keys: {}", e) - } - }) - .detach(); - } - - fn save_bunker(&self, uri: &NostrConnectURI, cx: &mut Context) { - let mut value = uri.to_string(); - - // Remove the secret param if it exists - if let Some(secret) = uri.secret() { - value = value.replace(secret, ""); - } - - let save_credential = - cx.write_credentials(KEYRING_USER_PATH, KEYRING_BUNKER, value.as_bytes()); - - cx.background_spawn(async move { - if let Err(e) = save_credential.await { - log::error!("Failed to save the Bunker URI: {}", e) - } - }) - .detach(); - } - fn shutdown_active_signer(&self, cx: &Context) { if let Some(signer) = self.active_signer.read(cx).clone() { cx.background_spawn(async move { diff --git a/crates/coop/src/views/new_account.rs b/crates/coop/src/views/new_account.rs index 98b9dee..fec6d51 100644 --- a/crates/coop/src/views/new_account.rs +++ b/crates/coop/src/views/new_account.rs @@ -1,6 +1,5 @@ use async_utility::task::spawn; use common::nip96_upload; -use global::constants::KEYRING_USER_PATH; use global::shared_state; use gpui::prelude::FluentBuilder; use gpui::{ @@ -8,6 +7,7 @@ use gpui::{ FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Window, }; +use identity::Identity; use nostr_sdk::prelude::*; use settings::AppSettings; use smol::fs; @@ -16,7 +16,7 @@ use ui::button::{Button, ButtonVariants}; use ui::dock_area::panel::{Panel, PanelEvent}; use ui::input::{InputState, TextInput}; use ui::popup_menu::PopupMenu; -use ui::{Disableable, Icon, IconName, Sizable, StyledExt}; +use ui::{ContextModal, Disableable, Icon, IconName, Sizable, StyledExt}; pub fn init(window: &mut Window, cx: &mut App) -> Entity { NewAccount::new(window, cx) @@ -65,37 +65,71 @@ impl NewAccount { } } - fn submit(&mut self, _window: &mut Window, cx: &mut Context) { + fn submit(&mut self, window: &mut Window, cx: &mut Context) { self.set_submitting(true, cx); let avatar = self.avatar_input.read(cx).value().to_string(); let name = self.name_input.read(cx).value().to_string(); let bio = self.bio_input.read(cx).value().to_string(); - let keys = Keys::generate(); let mut metadata = Metadata::new().display_name(name).about(bio); if let Ok(url) = Url::parse(&avatar) { metadata = metadata.picture(url); }; - let save_credential = cx.write_credentials( - KEYRING_USER_PATH, - keys.public_key().to_hex().as_str(), - keys.secret_key().as_secret_bytes(), - ); + let current_view = cx.entity().downgrade(); + let pwd_input = cx.new(|cx| InputState::new(window, cx).masked(true)); + let weak_input = pwd_input.downgrade(); - cx.background_spawn(async move { - if let Err(e) = save_credential.await { - log::error!("Failed to save keys: {}", e) - }; - shared_state().new_account(keys, metadata).await; - }) - .detach(); + window.open_modal(cx, move |this, _window, _cx| { + let metadata = metadata.clone(); + let weak_input = weak_input.clone(); + let view_cancel = current_view.clone(); + + this.overlay_closable(false) + .show_close(false) + .keyboard(false) + .confirm() + .on_cancel(move |_, window, cx| { + view_cancel + .update(cx, |_this, cx| { + window.push_notification("Password is invalid", cx) + }) + .ok(); + true + }) + .on_ok(move |_, _window, cx| { + let metadata = metadata.clone(); + let value = weak_input + .read_with(cx, |state, _cx| state.value().to_owned()) + .ok(); + + if let Some(password) = value { + Identity::global(cx).update(cx, |this, cx| { + this.new_identity(Keys::generate(), password.to_string(), metadata, cx); + }); + } + + true + }) + .child( + div() + .pt_4() + .px_4() + .w_full() + .flex() + .flex_col() + .gap_1() + .text_sm() + .child("Set password to encrypt your key *") + .child(TextInput::new(&pwd_input).small()), + ) + }); } fn upload(&mut self, window: &mut Window, cx: &mut Context) { - let nip96 = AppSettings::get_global(cx).settings().media_server.clone(); + let nip96 = AppSettings::get_global(cx).settings.media_server.clone(); let avatar_input = self.avatar_input.downgrade(); let paths = cx.prompt_for_paths(PathPromptOptions { files: true, diff --git a/crates/coop/src/views/onboarding.rs b/crates/coop/src/views/onboarding.rs index 705da74..78d882d 100644 --- a/crates/coop/src/views/onboarding.rs +++ b/crates/coop/src/views/onboarding.rs @@ -1,12 +1,25 @@ +use anyhow::anyhow; +use common::profile::RenderProfile; +use global::constants::ACCOUNT_D; +use global::shared_state; +use gpui::prelude::FluentBuilder; use gpui::{ - div, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Window, + div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, + FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, + StatefulInteractiveElement, Styled, Window, }; +use identity::Identity; +use itertools::Itertools; +use nostr_sdk::prelude::*; +use settings::AppSettings; use theme::ActiveTheme; +use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; +use ui::checkbox::Checkbox; use ui::dock_area::panel::{Panel, PanelEvent}; +use ui::indicator::Indicator; use ui::popup_menu::PopupMenu; -use ui::{Icon, IconName, StyledExt}; +use ui::{Disableable, Icon, IconName, Sizable, StyledExt}; use crate::chatspace; @@ -16,6 +29,8 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { pub struct Onboarding { name: SharedString, + local_account: Entity>, + loading: bool, closable: bool, zoomable: bool, focus_handle: FocusHandle, @@ -26,14 +41,73 @@ impl Onboarding { cx.new(|cx| Self::view(window, cx)) } - fn view(_window: &mut Window, cx: &mut Context) -> Self { + fn view(window: &mut Window, cx: &mut Context) -> Self { + let local_account = cx.new(|_| None); + + let task = cx.background_spawn(async move { + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(ACCOUNT_D) + .limit(1); + + if let Some(event) = shared_state() + .client + .database() + .query(filter) + .await? + .first_owned() + { + let public_key = event + .tags + .public_keys() + .copied() + .collect_vec() + .first() + .cloned() + .unwrap(); + + let metadata = shared_state() + .client + .database() + .metadata(public_key) + .await? + .unwrap_or_default(); + + let profile = Profile::new(public_key, metadata); + + Ok(profile) + } else { + Err(anyhow!("Not found")) + } + }); + + cx.spawn_in(window, async move |this, cx| { + if let Ok(profile) = task.await { + this.update(cx, |this, cx| { + this.local_account.update(cx, |this, cx| { + *this = Some(profile); + cx.notify(); + }); + }) + .ok(); + } + }) + .detach(); + Self { + local_account, name: "Onboarding".into(), + loading: false, closable: true, zoomable: true, focus_handle: cx.focus_handle(), } } + + fn set_loading(&mut self, status: bool, cx: &mut Context) { + self.loading = status; + cx.notify(); + } } impl Panel for Onboarding { @@ -71,6 +145,9 @@ impl Render for Onboarding { const TITLE: &str = "Welcome to Coop!"; const SUBTITLE: &str = "Secure Communication on Nostr."; + let auto_login = AppSettings::get_global(cx).settings.auto_login; + let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; + div() .py_4() .size_full() @@ -78,9 +155,9 @@ impl Render for Onboarding { .flex_col() .items_center() .justify_center() - .gap_10() .child( div() + .mb_10() .flex() .flex_col() .items_center() @@ -104,31 +181,121 @@ impl Render for Onboarding { .child(div().text_color(cx.theme().text_muted).child(SUBTITLE)), ), ) - .child( - div() - .w_72() - .flex() - .flex_col() - .gap_2() - .child( - Button::new("continue_btn") - .icon(Icon::new(IconName::ArrowRight)) - .label("Start Messaging") - .primary() - .reverse() - .on_click(cx.listener(move |_, _, window, cx| { - chatspace::new_account(window, cx); - })), + .map(|this| { + if let Some(profile) = self.local_account.read(cx).as_ref() { + this.relative() + .child( + div() + .id("account") + .mb_3() + .h_10() + .w_72() + .bg(cx.theme().element_background) + .text_color(cx.theme().element_foreground) + .rounded_lg() + .text_sm() + .map(|this| { + if self.loading { + this.child( + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child(Indicator::new().small()), + ) + } else { + this.child( + div() + .h_full() + .flex() + .items_center() + .justify_center() + .gap_2() + .child("Continue as") + .child( + div() + .flex() + .items_center() + .gap_1() + .font_semibold() + .child( + Avatar::new( + profile.render_avatar(proxy), + ) + .size(rems(1.5)), + ) + .child( + div() + .pb_px() + .child(profile.render_name()), + ), + ), + ) + } + }) + .hover(|this| this.bg(cx.theme().element_hover)) + .on_click(cx.listener(|this, _, window, cx| { + this.set_loading(true, cx); + Identity::global(cx).update(cx, |this, cx| { + this.load(window, cx); + }); + })), + ) + .child( + Checkbox::new("auto_login") + .label("Automatically log in next time") + .checked(auto_login) + .on_click(|_, _window, cx| { + AppSettings::global(cx).update(cx, |this, cx| { + this.settings.auto_login = !this.settings.auto_login; + cx.notify(); + }) + }), + ) + .child( + div().w_24().absolute().bottom_4().right_4().child( + Button::new("unload") + .icon(IconName::Logout) + .label("Logout") + .ghost() + .small() + .disabled(self.loading) + .on_click(|_, window, cx| { + Identity::global(cx).update(cx, |this, cx| { + this.unload(window, cx); + }); + }), + ), + ) + } else { + this.child( + div() + .w_72() + .flex() + .flex_col() + .gap_2() + .child( + Button::new("continue_btn") + .icon(Icon::new(IconName::ArrowRight)) + .label("Start Messaging") + .primary() + .reverse() + .on_click(cx.listener(move |_, _, window, cx| { + chatspace::new_account(window, cx); + })), + ) + .child( + Button::new("login_btn") + .label("Already have an account? Log in.") + .ghost() + .underline() + .on_click(cx.listener(move |_, _, window, cx| { + chatspace::login(window, cx); + })), + ), ) - .child( - Button::new("login_btn") - .label("Already have an account? Log in.") - .ghost() - .underline() - .on_click(cx.listener(move |_, _, window, cx| { - chatspace::login(window, cx); - })), - ), - ) + } + }) } } diff --git a/crates/coop/src/views/preferences.rs b/crates/coop/src/views/preferences.rs index 7d3056f..d308da8 100644 --- a/crates/coop/src/views/preferences.rs +++ b/crates/coop/src/views/preferences.rs @@ -1,13 +1,11 @@ use common::profile::RenderProfile; -use global::{ - constants::{DEFAULT_MODAL_WIDTH, NIP96_SERVER}, - shared_state, -}; +use global::constants::{DEFAULT_MODAL_WIDTH, NIP96_SERVER}; use gpui::{ div, http_client::Url, prelude::FluentBuilder, px, relative, rems, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement, Render, StatefulInteractiveElement, Styled, Window, }; +use identity::Identity; use settings::AppSettings; use theme::ActiveTheme; use ui::{ @@ -33,7 +31,7 @@ impl Preferences { pub fn new(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| { let media_server = AppSettings::get_global(cx) - .settings() + .settings .media_server .to_string(); @@ -84,7 +82,7 @@ impl Render for Preferences { "Use wsrv.nl to resize and downscale avatar pictures (saves ~50MB of data)"; let input_state = self.media_input.downgrade(); - let settings = AppSettings::get_global(cx).settings(); + let settings = AppSettings::get_global(cx).settings.as_ref(); div() .track_focus(&self.focus_handle) @@ -106,7 +104,7 @@ impl Render for Preferences { .font_semibold() .child("Account"), ) - .when_some(shared_state().identity(), |this, profile| { + .when_some(Identity::get_global(cx).profile(), |this, profile| { this.child( div() .w_full() diff --git a/crates/coop/src/views/profile.rs b/crates/coop/src/views/profile.rs index b3ee3be..05f6e5b 100644 --- a/crates/coop/src/views/profile.rs +++ b/crates/coop/src/views/profile.rs @@ -105,7 +105,7 @@ impl Profile { } fn upload(&mut self, window: &mut Window, cx: &mut Context) { - let nip96 = AppSettings::get_global(cx).settings().media_server.clone(); + let nip96 = AppSettings::get_global(cx).settings.media_server.clone(); let avatar_input = self.avatar_input.downgrade(); let paths = cx.prompt_for_paths(PathPromptOptions { files: true, diff --git a/crates/coop/src/views/sidebar/element.rs b/crates/coop/src/views/sidebar/element.rs index 4601046..540fea0 100644 --- a/crates/coop/src/views/sidebar/element.rs +++ b/crates/coop/src/views/sidebar/element.rs @@ -60,7 +60,7 @@ impl DisplayRoom { impl RenderOnce for DisplayRoom { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let handler = self.handler.clone(); - let hide_avatar = AppSettings::get_global(cx).settings().hide_user_avatars; + let hide_avatar = AppSettings::get_global(cx).settings.hide_user_avatars; self.base .id(self.ix) diff --git a/crates/coop/src/views/sidebar/mod.rs b/crates/coop/src/views/sidebar/mod.rs index 916a1ba..68f883a 100644 --- a/crates/coop/src/views/sidebar/mod.rs +++ b/crates/coop/src/views/sidebar/mod.rs @@ -17,6 +17,7 @@ use gpui::{ RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, }; +use identity::Identity; use itertools::Itertools; use nostr_sdk::prelude::*; use settings::AppSettings; @@ -70,7 +71,7 @@ impl Sidebar { let indicator = cx.new(|_| None); let local_result = cx.new(|_| None); let global_result = cx.new(|_| None); - let trusted_only = AppSettings::get_global(cx).settings().only_show_trusted; + let trusted_only = AppSettings::get_global(cx).settings.only_show_trusted; let find_input = cx.new(|cx| InputState::new(window, cx).placeholder("Find or start a conversation")); @@ -349,7 +350,7 @@ impl Sidebar { } fn render_account(&self, profile: &Profile, cx: &Context) -> impl IntoElement { - let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars; + let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; div() .px_3() @@ -491,10 +492,9 @@ impl Render for Sidebar { .flex_col() .gap_3() // Account - .when_some( - shared_state().identity.read_blocking().as_ref(), - |this, profile| this.child(self.render_account(profile, cx)), - ) + .when_some(Identity::get_global(cx).profile(), |this, profile| { + this.child(self.render_account(&profile, cx)) + }) // Search Input .child( div().px_3().w_full().h_7().flex_none().child( diff --git a/crates/coop/src/views/startup.rs b/crates/coop/src/views/startup.rs index 33c016d..bcfd918 100644 --- a/crates/coop/src/views/startup.rs +++ b/crates/coop/src/views/startup.rs @@ -65,6 +65,8 @@ impl Render for Startup { .flex() .flex_col() .items_center() + .justify_center() + .text_center() .gap_6() .child( svg() @@ -74,13 +76,10 @@ impl Render for Startup { ) .child( div() + .w_24() .flex() .items_center() - .gap_1p5() - .text_xs() - .text_center() - .text_color(cx.theme().text_muted) - .child("Connection in progress") + .justify_center() .child(Indicator::new().small()), ), ) diff --git a/crates/global/Cargo.toml b/crates/global/Cargo.toml index 01ca76f..9500109 100644 --- a/crates/global/Cargo.toml +++ b/crates/global/Cargo.toml @@ -5,7 +5,6 @@ edition.workspace = true publish.workspace = true [dependencies] -nostr-keyring.workspace = true nostr-connect.workspace = true nostr-sdk.workspace = true dirs.workspace = true diff --git a/crates/global/src/constants.rs b/crates/global/src/constants.rs index 5e455ff..d1171c6 100644 --- a/crates/global/src/constants.rs +++ b/crates/global/src/constants.rs @@ -1,9 +1,10 @@ pub const APP_NAME: &str = "Coop"; pub const APP_ID: &str = "su.reya.coop"; pub const APP_PUBKEY: &str = "b1813fb01274b32cc5db6d1198e7c79dda0fb430899f63c7064f651a41d44f2b"; -pub const KEYRING_PATH: &str = "Coop Safe Storage"; -pub const KEYRING_USER_PATH: &str = "coop"; -pub const KEYRING_BUNKER: &str = "bunker"; +pub const KEYRING_URL: &str = "Coop Safe Storage"; + +pub const ACCOUNT_D: &str = "coop:account"; +pub const SETTINGS_D: &str = "coop:settings"; /// Bootstrap Relays. pub const BOOTSTRAP_RELAYS: [&str; 4] = [ @@ -12,9 +13,27 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [ "wss://user.kindpag.es", "wss://relaydiscovery.com", ]; + +/// NIP65 Relays. Used for new account +pub const NIP65_RELAYS: [&str; 4] = [ + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://relay.nostr.net", + "wss://nos.lol", +]; + +/// Messaging Relays. Used for new account +pub const NIP17_RELAYS: [&str; 2] = ["wss://auth.nostr1.com", "wss://relay.0xchat.com"]; + /// Search Relays. pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"]; +/// Default relay for Nostr Connect +pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app"; + +/// Default timeout for Nostr Connect +pub const NOSTR_CONNECT_TIMEOUT: u64 = 300; + /// Unique ID for new message subscription. pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps"; /// Unique ID for all messages subscription. @@ -38,10 +57,3 @@ pub const NIP96_SERVER: &str = "https://nostrmedia.com"; pub(crate) const GLOBAL_CHANNEL_LIMIT: usize = 2048; pub(crate) const BATCH_CHANNEL_LIMIT: usize = 1024; -pub(crate) const NIP17_RELAYS: [&str; 2] = ["wss://auth.nostr1.com", "wss://relay.0xchat.com"]; -pub(crate) const NIP65_RELAYS: [&str; 4] = [ - "wss://relay.damus.io", - "wss://relay.primal.net", - "wss://relay.nostr.net", - "wss://nos.lol", -]; diff --git a/crates/global/src/lib.rs b/crates/global/src/lib.rs index bad9962..7fd8a4f 100644 --- a/crates/global/src/lib.rs +++ b/crates/global/src/lib.rs @@ -1,29 +1,19 @@ -//! Global state management for the Nostr client application. -//! -//! This module provides a singleton global state that manages: -//! - Nostr client connections and event handling -//! - User identity and profile management -//! - Batched metadata fetching for performance -//! - Cross-component communication via channels - use std::collections::{BTreeMap, BTreeSet}; -use std::mem; use std::sync::OnceLock; use std::time::Duration; +use std::{fs, mem}; use anyhow::{anyhow, Error}; use constants::{ ALL_MESSAGES_SUB_ID, APP_ID, APP_PUBKEY, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT, METADATA_BATCH_TIMEOUT, NEW_MESSAGE_SUB_ID, SEARCH_RELAYS, }; -use nostr_keyring::prelude::*; use nostr_sdk::prelude::*; use paths::nostr_file; use smol::lock::RwLock; -use crate::constants::{ - BATCH_CHANNEL_LIMIT, GLOBAL_CHANNEL_LIMIT, KEYRING_PATH, NIP17_RELAYS, NIP65_RELAYS, -}; +use crate::constants::{BATCH_CHANNEL_LIMIT, GLOBAL_CHANNEL_LIMIT}; +use crate::paths::support_dir; pub mod constants; pub mod paths; @@ -50,11 +40,9 @@ pub enum NostrSignal { pub struct Globals { /// The Nostr SDK client pub client: Client, - /// Cryptographic keys for signing Nostr events - pub client_signer: Keys, - /// Current user's profile information (pubkey and metadata) - pub identity: RwLock>, - /// Auto-close options for subscriptions to prevent memory leaks + /// Determines if this is the first time user run Coop + pub first_run: bool, + /// Auto-close options for subscriptions pub auto_close: Option, /// Channel sender for broadcasting global Nostr events to UI pub global_sender: smol::channel::Sender, @@ -78,18 +66,7 @@ pub fn shared_state() -> &'static Globals { .install_default() .ok(); - let keyring = NostrKeyring::new(KEYRING_PATH); - // Get the client signer or generate a new one if it doesn't exist - let client_signer = if let Ok(keys) = keyring.get("client") { - keys - } else { - let keys = Keys::generate(); - if let Err(e) = keyring.set("client", &keys) { - log::error!("Failed to save client keys: {}", e); - } - keys - }; - + let first_run = is_first_run().unwrap_or(true); let opts = Options::new().gossip(true); let lmdb = NostrLMDB::open(nostr_file()).expect("Database is NOT initialized"); @@ -101,12 +78,11 @@ pub fn shared_state() -> &'static Globals { Globals { client: ClientBuilder::default().database(lmdb).opts(opts).build(), - identity: RwLock::new(None), persons: RwLock::new(BTreeMap::new()), auto_close: Some( SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE), ), - client_signer, + first_run, global_sender, global_receiver, batch_sender, @@ -120,6 +96,7 @@ impl Globals { pub async fn start(&self) { self.connect().await; self.subscribe_for_app_updates().await; + self.preload_metadata().await; nostr_sdk::async_utility::task::spawn(async move { let mut batch: BTreeSet = BTreeSet::new(); @@ -225,112 +202,21 @@ impl Globals { } } - /// Sets a new signer for the client and updates user identity - pub async fn set_signer(&self, signer: S) -> Result<(), Error> - where - S: NostrSigner + 'static, - { - let public_key = signer.get_public_key().await?; - - // Update signer - self.client.set_signer(signer).await; - - // Fetch user's metadata - let metadata = shared_state() - .client - .fetch_metadata(public_key, Duration::from_secs(2)) - .await? - .unwrap_or_default(); - - let profile = Profile::new(public_key, metadata); - let mut identity_guard = self.identity.write().await; - // Update the identity - *identity_guard = Some(profile); - - // Subscribe for user's data - nostr_sdk::async_utility::task::spawn(async move { - shared_state().subscribe_for_user_data().await; - }); - - // Notify GPUi via the global channel - self.global_sender.send(NostrSignal::SignerUpdated).await?; - - Ok(()) - } - pub async fn unset_signer(&self) { self.client.reset().await; + if let Ok(signer) = self.client.signer().await { + if let Ok(public_key) = signer.get_public_key().await { + let file = support_dir().join(format!(".{}", public_key.to_bech32().unwrap())); + fs::remove_file(&file).ok(); + } + } + if let Err(e) = self.global_sender.send(NostrSignal::SignerUnset).await { log::error!("Failed to send signal to global channel: {}", e); } } - /// Creates a new account with the given keys and metadata - pub async fn new_account(&self, keys: Keys, metadata: Metadata) { - let profile = Profile::new(keys.public_key(), metadata.clone()); - - // Update signer - self.client.set_signer(keys).await; - - // Set metadata - self.client.set_metadata(&metadata).await.ok(); - - // Create relay list - let builder = EventBuilder::new(Kind::RelayList, "").tags( - NIP65_RELAYS.into_iter().filter_map(|url| { - if let Ok(url) = RelayUrl::parse(url) { - Some(Tag::relay_metadata(url, None)) - } else { - None - } - }), - ); - - if let Err(e) = self.client.send_event_builder(builder).await { - log::error!("Failed to send relay list event: {}", e); - }; - - // Create messaging relay list - let builder = EventBuilder::new(Kind::InboxRelays, "").tags( - NIP17_RELAYS.into_iter().filter_map(|url| { - if let Ok(url) = RelayUrl::parse(url) { - Some(Tag::relay(url)) - } else { - None - } - }), - ); - - if let Err(e) = self.client.send_event_builder(builder).await { - log::error!("Failed to send messaging relay list event: {}", e); - }; - - let mut guard = self.identity.write().await; - - // Update the identity - *guard = Some(profile); - - // Notify GPUi via the global channel - self.global_sender - .send(NostrSignal::SignerUpdated) - .await - .ok(); - - // Subscribe - self.subscribe_for_user_data().await; - } - - /// Returns the current user's profile (blocking) - pub fn identity(&self) -> Option { - self.identity.read_blocking().as_ref().cloned() - } - - /// Returns the current user's profile (async) - pub async fn async_identity(&self) -> Option { - self.identity.read().await.as_ref().cloned() - } - /// Gets a person's profile from cache or creates default (blocking) pub fn person(&self, public_key: &PublicKey) -> Profile { let metadata = if let Some(metadata) = self.persons.read_blocking().get(public_key) { @@ -354,7 +240,7 @@ impl Globals { } /// Connects to bootstrap and configured relays - async fn connect(&self) { + pub(crate) async fn connect(&self) { for relay in BOOTSTRAP_RELAYS.into_iter() { if let Err(e) = self.client.add_relay(relay).await { log::error!("Failed to add relay {}: {}", relay, e); @@ -374,13 +260,7 @@ impl Globals { } /// Subscribes to user-specific data feeds (DMs, mentions, etc.) - async fn subscribe_for_user_data(&self) { - let Some(profile) = self.identity.read().await.clone() else { - return; - }; - - let public_key = profile.public_key(); - + pub async fn subscribe_for_user_data(&self, public_key: PublicKey) { let metadata = Filter::new() .kinds(vec![ Kind::Metadata, @@ -435,7 +315,7 @@ impl Globals { } /// Subscribes to application update notifications - async fn subscribe_for_app_updates(&self) { + pub(crate) async fn subscribe_for_app_updates(&self) { let coordinate = Coordinate { kind: Kind::Custom(32267), public_key: PublicKey::from_hex(APP_PUBKEY).expect("App Pubkey is invalid"), @@ -458,8 +338,22 @@ impl Globals { log::info!("Subscribing to app updates..."); } + pub(crate) async fn preload_metadata(&self) { + let filter = Filter::new().kind(Kind::Metadata).limit(100); + if let Ok(events) = self.client.database().query(filter).await { + for event in events.into_iter() { + self.insert_person(&event).await; + } + } + } + /// Stores an unwrapped event in local database with reference to original - async fn set_unwrapped(&self, root: EventId, event: &Event, keys: &Keys) -> Result<(), Error> { + pub(crate) async fn set_unwrapped( + &self, + root: EventId, + event: &Event, + keys: &Keys, + ) -> Result<(), Error> { // Must be use the random generated keys to sign this event let event = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json()) .tags(vec![Tag::identifier(root), Tag::event(root)]) @@ -473,9 +367,9 @@ impl Globals { } /// Retrieves a previously unwrapped event from local database - async fn get_unwrapped(&self, target: EventId) -> Result { + pub(crate) async fn get_unwrapped(&self, target: EventId) -> Result { let filter = Filter::new() - .kind(Kind::Custom(30078)) + .kind(Kind::ApplicationSpecificData) .event(target) .limit(1); @@ -487,7 +381,7 @@ impl Globals { } /// Unwraps a gift-wrapped event and processes its contents - async fn unwrap_event(&self, subscription_id: &SubscriptionId, event: &Event) { + pub(crate) async fn unwrap_event(&self, subscription_id: &SubscriptionId, event: &Event) { let new_messages_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID); let random_keys = Keys::generate(); @@ -527,7 +421,7 @@ impl Globals { } /// Extracts public keys from contact list and queues metadata sync - async fn extract_pubkeys_and_sync(&self, event: &Event) { + pub(crate) async fn extract_pubkeys_and_sync(&self, event: &Event) { if let Ok(signer) = self.client.signer().await { if let Ok(public_key) = signer.get_public_key().await { if public_key == event.pubkey { @@ -539,7 +433,7 @@ impl Globals { } /// Fetches metadata for a batch of public keys - async fn sync_data_for_pubkeys(&self, public_keys: BTreeSet) { + pub(crate) async fn sync_data_for_pubkeys(&self, public_keys: BTreeSet) { let kinds = vec![ Kind::Metadata, Kind::ContactList, @@ -561,7 +455,7 @@ impl Globals { } /// Inserts or updates a person's metadata from a Kind::Metadata event - async fn insert_person(&self, event: &Event) { + pub(crate) async fn insert_person(&self, event: &Event) { let metadata = Metadata::from_json(&event.content).ok(); self.persons @@ -577,7 +471,7 @@ impl Globals { } /// Notifies UI of application updates via global channel - async fn notify_update(&self, event: &Event) { + pub(crate) async fn notify_update(&self, event: &Event) { let filter = Filter::new() .ids(event.tags.event_ids().copied()) .kind(Kind::FileMetadata); @@ -596,3 +490,14 @@ impl Globals { } } } + +fn is_first_run() -> Result { + let flag = support_dir().join(".coop_first_run"); + + if !flag.exists() { + fs::write(&flag, "")?; + Ok(true) // First run + } else { + Ok(false) // Not first run + } +} diff --git a/crates/identity/Cargo.toml b/crates/identity/Cargo.toml new file mode 100644 index 0000000..dd78c26 --- /dev/null +++ b/crates/identity/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "identity" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +ui = { path = "../ui" } +global = { path = "../global" } +common = { path = "../common" } +client_keys = { path = "../client_keys" } +settings = { path = "../settings" } + +nostr-sdk.workspace = true +nostr-connect.workspace = true +oneshot.workspace = true +gpui.workspace = true +anyhow.workspace = true +log.workspace = true +smallvec.workspace = true diff --git a/crates/identity/src/lib.rs b/crates/identity/src/lib.rs new file mode 100644 index 0000000..db6742d --- /dev/null +++ b/crates/identity/src/lib.rs @@ -0,0 +1,511 @@ +use std::time::Duration; + +use anyhow::{anyhow, Error}; +use client_keys::ClientKeys; +use common::handle_auth::CoopAuthUrlHandler; +use global::{ + constants::{ACCOUNT_D, NIP17_RELAYS, NIP65_RELAYS, NOSTR_CONNECT_TIMEOUT}, + shared_state, NostrSignal, +}; +use gpui::{ + div, prelude::FluentBuilder, red, App, AppContext, Context, Entity, Global, ParentElement, + SharedString, Styled, Subscription, Task, Window, +}; +use nostr_connect::prelude::*; +use nostr_sdk::prelude::*; +use settings::AppSettings; +use smallvec::{smallvec, SmallVec}; +use ui::{ + input::{InputState, TextInput}, + notification::Notification, + ContextModal, Sizable, +}; + +pub fn init(window: &mut Window, cx: &mut App) { + Identity::set_global(cx.new(|cx| Identity::new(window, cx)), cx); +} + +struct GlobalIdentity(Entity); + +impl Global for GlobalIdentity {} + +pub struct Identity { + profile: Option, + #[allow(dead_code)] + subscriptions: SmallVec<[Subscription; 1]>, +} + +impl Identity { + /// Retrieve the Global Identity instance + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + /// Retrieve the Identity instance + pub fn get_global(cx: &App) -> &Self { + cx.global::().0.read(cx) + } + + /// Set the Global Identity instance + pub(crate) fn set_global(state: Entity, cx: &mut App) { + cx.set_global(GlobalIdentity(state)); + } + + pub(crate) fn new(window: &mut Window, cx: &mut Context) -> Self { + let client_keys = ClientKeys::global(cx); + let mut subscriptions = smallvec![]; + + subscriptions.push( + cx.observe_in(&client_keys, window, |this, state, window, cx| { + let auto_login = AppSettings::get_global(cx).settings.auto_login; + let has_client_keys = state.read(cx).has_keys(); + + // Skip auto login if the user hasn't enabled auto login + if has_client_keys && auto_login { + this.load(window, cx); + } else { + this.set_profile(None, cx); + } + }), + ); + + Self { + profile: None, + subscriptions, + } + } + + pub fn load(&mut self, window: &mut Window, cx: &mut Context) { + let task = cx.background_spawn(async move { + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(ACCOUNT_D) + .limit(1); + + if let Some(event) = shared_state() + .client + .database() + .query(filter) + .await? + .first_owned() + { + let secret = event.content; + let is_bunker = secret.starts_with("bunker://"); + + Ok((secret, is_bunker)) + } else { + Err(anyhow!("Not found")) + } + }); + + cx.spawn_in(window, async move |this, cx| { + if let Ok((secret, is_bunker)) = task.await { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + this.login(&secret, is_bunker, window, cx); + }) + .ok(); + }) + .ok(); + } else { + this.update(cx, |this, cx| { + this.set_profile(None, cx); + }) + .ok(); + } + }) + .detach(); + } + + pub fn unload(&mut self, window: &mut Window, cx: &mut Context) { + let task = cx.background_spawn(async move { + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(ACCOUNT_D) + .limit(1); + + // Unset signer + shared_state().client.unset_signer().await; + + // Delete account + shared_state() + .client + .database() + .delete(filter) + .await + .is_ok() + }); + + cx.spawn_in(window, async move |this, cx| { + if task.await { + this.update(cx, |this, cx| { + this.set_profile(None, cx); + }) + .ok(); + } + }) + .detach(); + } + + pub(crate) fn login( + &mut self, + secret: &str, + is_bunker: bool, + window: &mut Window, + cx: &mut Context, + ) { + if is_bunker { + if let Ok(uri) = NostrConnectURI::parse(secret) { + self.login_with_bunker(uri, window, cx); + } else { + window.push_notification(Notification::error("Bunker URI is invalid"), cx); + self.set_profile(None, cx); + } + } else if let Ok(enc) = EncryptedSecretKey::from_bech32(secret) { + self.login_with_keys(enc, window, cx); + } else { + window.push_notification(Notification::error("Secret Key is invalid"), cx); + self.set_profile(None, cx); + } + } + + pub(crate) fn login_with_bunker( + &mut self, + uri: NostrConnectURI, + window: &mut Window, + cx: &mut Context, + ) { + let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT); + let client_keys = ClientKeys::get_global(cx).keys(); + + let Ok(mut signer) = NostrConnect::new(uri, client_keys, timeout, None) else { + window.push_notification(Notification::error("Bunker URI is invalid"), cx); + self.set_profile(None, cx); + return; + }; + // Automatically open auth url + signer.auth_url_handler(CoopAuthUrlHandler); + + let (tx, rx) = oneshot::channel::>(); + + // Verify the signer, make sure Remote Signer is connected + cx.background_spawn(async move { + if signer.bunker_uri().await.is_ok() { + tx.send(Some(signer)).ok(); + } else { + tx.send(None).ok(); + } + }) + .detach(); + + cx.spawn_in(window, async move |this, cx| { + match rx.await { + Ok(Some(signer)) => { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + this.set_signer(signer, window, cx); + }) + .ok(); + }) + .ok(); + } + _ => { + cx.update(|window, cx| { + window.push_notification( + Notification::error("Failed to connect to the remote signer"), + cx, + ); + this.update(cx, |this, cx| { + this.set_profile(None, cx); + }) + .ok(); + }) + .ok(); + } + }; + }) + .detach(); + } + + pub(crate) fn login_with_keys( + &mut self, + enc: EncryptedSecretKey, + window: &mut Window, + cx: &mut Context, + ) { + let pwd_input: Entity = cx.new(|cx| InputState::new(window, cx).masked(true)); + let weak_input = pwd_input.downgrade(); + let error: Entity> = cx.new(|_| None); + let weak_error = error.downgrade(); + + window.open_modal(cx, move |this, _window, cx| { + let weak_input = weak_input.clone(); + let weak_error = weak_error.clone(); + + this.overlay_closable(false) + .show_close(false) + .keyboard(false) + .confirm() + .on_cancel(move |_, _window, cx| { + Identity::global(cx).update(cx, |this, cx| { + this.set_profile(None, cx); + }); + true + }) + .on_ok(move |_, window, cx| { + let value = weak_input + .read_with(cx, |state, _cx| state.value().to_string()) + .ok(); + + if let Some(password) = value { + if password.is_empty() { + weak_error + .update(cx, |this, cx| { + *this = Some("Password cannot be empty".into()); + cx.notify(); + }) + .ok(); + return false; + }; + + Identity::global(cx).update(cx, |_, cx| { + let weak_error = weak_error.clone(); + let task: Task> = cx.background_spawn(async move { + // Decrypt the password in the background to prevent blocking the main thread + enc.decrypt(&password).ok() + }); + + cx.spawn_in(window, async move |this, cx| { + if let Some(secret) = task.await { + cx.update(|window, cx| { + window.close_modal(cx); + this.update(cx, |this, cx| { + this.set_signer(Keys::new(secret), window, cx); + }) + .ok(); + }) + .ok(); + } else { + weak_error + .update(cx, |this, cx| { + *this = Some("Invalid password".into()); + cx.notify(); + }) + .ok(); + } + }) + .detach(); + }); + } + + false + }) + .child( + div() + .pt_4() + .px_4() + .w_full() + .flex() + .flex_col() + .gap_1() + .text_sm() + .child("Password to decrypt your key *") + .child(TextInput::new(&pwd_input).small()) + .when_some(error.read(cx).as_ref(), |this, error| { + this.child( + div() + .text_xs() + .italic() + .text_color(red()) + .child(error.clone()), + ) + }), + ) + }); + } + + /// Sets a new signer for the client and updates user identity + pub fn set_signer(&self, signer: S, window: &mut Window, cx: &mut Context) + where + S: NostrSigner + 'static, + { + let task: Task> = cx.background_spawn(async move { + let public_key = signer.get_public_key().await?; + + // Update signer + shared_state().client.set_signer(signer).await; + + // Fetch user's metadata + let metadata = shared_state() + .client + .fetch_metadata(public_key, Duration::from_secs(2)) + .await? + .unwrap_or_default(); + + // Create user's profile with public key and metadata + let profile = Profile::new(public_key, metadata); + + // Subscribe for user's data + nostr_sdk::async_utility::task::spawn(async move { + shared_state().subscribe_for_user_data(public_key).await; + }); + + // Notify GPUi via the global channel + shared_state() + .global_sender + .send(NostrSignal::SignerUpdated) + .await?; + + Ok(profile) + }); + + cx.spawn_in(window, async move |this, cx| match task.await { + Ok(profile) => { + this.update(cx, |this, cx| { + this.set_profile(Some(profile), cx); + }) + .ok(); + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification(Notification::error(e.to_string()), cx); + }) + .ok(); + } + }) + .detach(); + } + + /// Creates a new identity with the given keys and metadata + pub fn new_identity( + &mut self, + keys: Keys, + password: String, + metadata: Metadata, + cx: &mut Context, + ) { + let profile = Profile::new(keys.public_key(), metadata.clone()); + // Save keys for further use + self.write_keys(&keys, password, cx); + + cx.background_spawn(async move { + // Update signer + shared_state().client.set_signer(keys).await; + // Set metadata + shared_state().client.set_metadata(&metadata).await.ok(); + + // Create relay list + let builder = EventBuilder::new(Kind::RelayList, "").tags( + NIP65_RELAYS.into_iter().filter_map(|url| { + if let Ok(url) = RelayUrl::parse(url) { + Some(Tag::relay_metadata(url, None)) + } else { + None + } + }), + ); + + if let Err(e) = shared_state().client.send_event_builder(builder).await { + log::error!("Failed to send relay list event: {}", e); + }; + + // Create messaging relay list + let builder = EventBuilder::new(Kind::InboxRelays, "").tags( + NIP17_RELAYS.into_iter().filter_map(|url| { + if let Ok(url) = RelayUrl::parse(url) { + Some(Tag::relay(url)) + } else { + None + } + }), + ); + + if let Err(e) = shared_state().client.send_event_builder(builder).await { + log::error!("Failed to send messaging relay list event: {}", e); + }; + + // Notify GPUi via the global channel + shared_state() + .global_sender + .send(NostrSignal::SignerUpdated) + .await + .ok(); + + // Subscribe + shared_state() + .subscribe_for_user_data(profile.public_key()) + .await; + }) + .detach(); + } + + pub fn write_bunker(&self, uri: &NostrConnectURI, cx: &mut Context) { + let mut value = uri.to_string(); + + let Some(public_key) = uri.remote_signer_public_key().cloned() else { + log::error!("Remote Signer's public key not found"); + return; + }; + + // Remove the secret param if it exists + if let Some(secret) = uri.secret() { + value = value.replace(secret, ""); + } + + cx.background_spawn(async move { + let keys = Keys::generate(); + let builder = EventBuilder::new(Kind::ApplicationSpecificData, value).tags(vec![ + Tag::identifier(ACCOUNT_D), + Tag::public_key(public_key), + ]); + + if let Ok(event) = builder.sign(&keys).await { + if let Err(e) = shared_state().client.database().save_event(&event).await { + log::error!("Failed to save event: {e}"); + }; + } + }) + .detach(); + } + + pub fn write_keys(&self, keys: &Keys, password: String, cx: &mut Context) { + let keys = keys.to_owned(); + let public_key = keys.public_key(); + + cx.background_spawn(async move { + if let Ok(enc_key) = + EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium) + { + let keys = Keys::generate(); + let builder = + EventBuilder::new(Kind::ApplicationSpecificData, enc_key.to_bech32().unwrap()) + .tags(vec![ + Tag::identifier(ACCOUNT_D), + Tag::public_key(public_key), + ]); + + if let Ok(event) = builder.sign(&keys).await { + if let Err(e) = shared_state().client.database().save_event(&event).await { + log::error!("Failed to save event: {e}"); + }; + } + } + }) + .detach(); + } + + pub(crate) fn set_profile(&mut self, profile: Option, cx: &mut Context) { + self.profile = profile; + cx.notify(); + } + + /// Returns the current profile + pub fn profile(&self) -> Option { + self.profile.as_ref().cloned() + } + + /// Returns true if a profile is currently loaded + pub fn has_profile(&self) -> bool { + self.profile.is_some() + } +} diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 9e1476a..a79355d 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -1,5 +1,5 @@ use anyhow::anyhow; -use global::shared_state; +use global::{constants::SETTINGS_D, shared_state}; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; @@ -7,6 +7,7 @@ use smallvec::{smallvec, SmallVec}; pub fn init(cx: &mut App) { let state = cx.new(AppSettings::new); + // Observe for state changes and save settings to database state.update(cx, |this, cx| { this.subscriptions @@ -25,6 +26,7 @@ pub struct Settings { pub hide_user_avatars: bool, pub only_show_trusted: bool, pub backup_messages: bool, + pub auto_login: bool, } impl AsRef for Settings { @@ -40,7 +42,7 @@ impl Global for GlobalAppSettings {} pub struct AppSettings { pub settings: Settings, #[allow(dead_code)] - subscriptions: SmallVec<[Subscription; 2]>, + subscriptions: SmallVec<[Subscription; 1]>, } impl AppSettings { @@ -54,7 +56,7 @@ impl AppSettings { cx.global::().0.read(cx) } - /// Set the global Settings instance + /// Set the Global Settings instance pub(crate) fn set_global(state: Entity, cx: &mut App) { cx.set_global(GlobalAppSettings(state)); } @@ -66,6 +68,7 @@ impl AppSettings { hide_user_avatars: false, only_show_trusted: false, backup_messages: true, + auto_login: false, }; let mut subscriptions = smallvec![]; @@ -80,15 +83,11 @@ impl AppSettings { } } - pub fn settings(&self) -> &Settings { - self.settings.as_ref() - } - - fn get_settings_from_db(&self, cx: &mut Context) { + pub(crate) fn get_settings_from_db(&self, cx: &mut Context) { let task: Task> = cx.background_spawn(async move { let filter = Filter::new() .kind(Kind::ApplicationSpecificData) - .identifier("coop-settings") + .identifier(SETTINGS_D) .limit(1); if let Some(event) = shared_state() @@ -117,19 +116,13 @@ impl AppSettings { .detach(); } - fn set_settings(&self, cx: &mut Context) { + pub(crate) fn set_settings(&self, cx: &mut Context) { if let Ok(content) = serde_json::to_string(&self.settings) { cx.background_spawn(async move { - let Some(identity) = shared_state().identity() else { - return; - }; - let keys = Keys::generate(); - let ident = Tag::identifier("coop-settings"); if let Ok(event) = EventBuilder::new(Kind::ApplicationSpecificData, content) - .tags(vec![ident]) - .build(identity.public_key()) + .tags(vec![Tag::identifier(SETTINGS_D)]) .sign(&keys) .await { diff --git a/crates/ui/src/checkbox.rs b/crates/ui/src/checkbox.rs index 7e6c837..75c94fb 100644 --- a/crates/ui/src/checkbox.rs +++ b/crates/ui/src/checkbox.rs @@ -1,6 +1,6 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - div, relative, svg, App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, + div, svg, App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _, Window, }; use theme::ActiveTheme; @@ -65,35 +65,29 @@ impl Selectable for Checkbox { impl RenderOnce for Checkbox { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let (color, icon_color) = if self.disabled { - (cx.theme().ghost_element_disabled, cx.theme().text_muted) + let icon_color = if self.disabled { + cx.theme().icon_muted } else { - (cx.theme().text_accent, cx.theme().surface_background) + cx.theme().icon_accent }; h_flex() .id(self.id) .gap_2() .items_center() - .line_height(relative(1.)) .child( v_flex() - .relative() - .border_1() - .border_color(color) - .rounded_sm() - .size_4() .flex_shrink_0() - .map(|this| match self.checked { - false => this.bg(cx.theme().ghost_element_background), - _ => this.bg(color), - }) + .relative() + .rounded_sm() + .size_5() + .bg(cx.theme().elevated_surface_background) .child( svg() .absolute() - .top_px() - .left_px() - .size_3() + .top_0p5() + .left_0p5() + .size_4() .text_color(icon_color) .map(|this| match self.checked { true => this.path(IconName::Check.path()), @@ -108,7 +102,7 @@ impl RenderOnce for Checkbox { .w_full() .overflow_x_hidden() .text_ellipsis() - .line_height(relative(1.)) + .text_sm() .child(label), ) } else { diff --git a/crates/ui/src/modal.rs b/crates/ui/src/modal.rs index 6688f8e..b5d7a7c 100644 --- a/crates/ui/src/modal.rs +++ b/crates/ui/src/modal.rs @@ -12,7 +12,7 @@ use crate::{ actions::{Cancel, Confirm}, animation::cubic_bezier, button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _}, - h_flex, v_flex, ContextModal, IconName, Root, StyledExt, + h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt, }; const CONTEXT: &str = "Modal"; @@ -45,7 +45,7 @@ impl Default for ModalButtonProps { ok_text: None, ok_variant: ButtonVariant::Primary, cancel_text: None, - cancel_variant: ButtonVariant::default(), + cancel_variant: ButtonVariant::Ghost, } } } @@ -120,7 +120,7 @@ impl Modal { footer: None, content: v_flex(), margin_top: None, - width: px(480.), + width: px(380.), max_width: None, overlay: true, keyboard: true, @@ -291,12 +291,15 @@ impl RenderOnce for Modal { let render_ok: RenderButtonFn = Box::new({ let on_ok = on_ok.clone(); let on_close = on_close.clone(); - let ok_text = self.button_props.ok_text.unwrap_or_else(|| "Ok".into()); let ok_variant = self.button_props.ok_variant; + let ok_text = self.button_props.ok_text.unwrap_or_else(|| "OK".into()); + move |_, _| { Button::new("ok") .label(ok_text) .with_variant(ok_variant) + .small() + .flex_1() .on_click({ let on_ok = on_ok.clone(); let on_close = on_close.clone(); @@ -315,18 +318,22 @@ impl RenderOnce for Modal { .into_any_element() } }); + let render_cancel: RenderButtonFn = Box::new({ let on_cancel = on_cancel.clone(); let on_close = on_close.clone(); + let cancel_variant = self.button_props.cancel_variant; let cancel_text = self .button_props .cancel_text .unwrap_or_else(|| "Cancel".into()); - let cancel_variant = self.button_props.cancel_variant; + move |_, _| { Button::new("cancel") .label(cancel_text) .with_variant(cancel_variant) + .small() + .flex_1() .on_click({ let on_cancel = on_cancel.clone(); let on_close = on_close.clone(); @@ -344,15 +351,18 @@ impl RenderOnce for Modal { }); let window_paddings = crate::window_border::window_paddings(window, cx); + let view_size = window.viewport_size() - gpui::size( window_paddings.left + window_paddings.right, window_paddings.top + window_paddings.bottom, ); + let bounds = Bounds { origin: Point::default(), size: view_size, }; + let offset_top = px(layer_ix as f32 * 16.); let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top; let x = bounds.center().x - self.width / 2.; @@ -469,12 +479,14 @@ impl RenderOnce for Modal { .when(self.footer.is_some(), |this| { let footer = self.footer.unwrap(); - this.child(h_flex().gap_2().justify_end().children(footer( - render_ok, - render_cancel, - window, - cx, - ))) + this.child( + h_flex().p_4().gap_1p5().justify_center().children(footer( + render_ok, + render_cancel, + window, + cx, + )), + ) }) .with_animation( "slide-down",