diff --git a/Cargo.lock b/Cargo.lock index 4d765ba..f78fe2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,20 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "account" -version = "0.0.0" -dependencies = [ - "anyhow", - "common", - "gpui", - "log", - "nostr-sdk", - "oneshot", - "smol", - "state", -] - [[package]] name = "addr2line" version = "0.24.2" @@ -941,6 +927,7 @@ dependencies = [ "anyhow", "chrono", "common", + "global", "gpui", "itertools 0.13.0", "log", @@ -948,7 +935,6 @@ dependencies = [ "oneshot", "smallvec", "smol", - "state", ] [[package]] @@ -1144,11 +1130,13 @@ dependencies = [ "anyhow", "chrono", "dirs 5.0.1", + "global", "gpui", "itertools 0.13.0", "nostr-sdk", "qrcode-generator", "random_name_generator", + "smallvec", ] [[package]] @@ -1190,12 +1178,12 @@ checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" name = "coop" version = "0.1.3" dependencies = [ - "account", "anyhow", "chats", "common", "dirs 5.0.1", "futures", + "global", "gpui", "itertools 0.13.0", "log", @@ -1209,7 +1197,6 @@ dependencies = [ "serde_json", "smallvec", "smol", - "state", "tracing-subscriber", "ui", ] @@ -2083,6 +2070,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +[[package]] +name = "global" +version = "0.0.0" +dependencies = [ + "dirs 5.0.1", + "nostr-sdk", + "smol", + "whoami", +] + [[package]] name = "globset" version = "0.4.15" @@ -3329,7 +3326,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr" version = "0.39.0" -source = "git+https://github.com/rust-nostr/nostr#16ad5e1190733f6e20d84891165e54d30f917e6d" +source = "git+https://github.com/reyamir/nostr?branch=feat%2Fimprove-nip17#82c0f9e43519e66503491935924b5d9dc29d1dff" dependencies = [ "aes", "base64", @@ -3353,7 +3350,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.39.0" -source = "git+https://github.com/rust-nostr/nostr#16ad5e1190733f6e20d84891165e54d30f917e6d" +source = "git+https://github.com/reyamir/nostr?branch=feat%2Fimprove-nip17#82c0f9e43519e66503491935924b5d9dc29d1dff" dependencies = [ "async-utility", "nostr", @@ -3365,7 +3362,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.39.0" -source = "git+https://github.com/rust-nostr/nostr#16ad5e1190733f6e20d84891165e54d30f917e6d" +source = "git+https://github.com/reyamir/nostr?branch=feat%2Fimprove-nip17#82c0f9e43519e66503491935924b5d9dc29d1dff" dependencies = [ "flatbuffers", "lru", @@ -3376,7 +3373,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.39.0" -source = "git+https://github.com/rust-nostr/nostr#16ad5e1190733f6e20d84891165e54d30f917e6d" +source = "git+https://github.com/reyamir/nostr?branch=feat%2Fimprove-nip17#82c0f9e43519e66503491935924b5d9dc29d1dff" dependencies = [ "async-utility", "heed", @@ -3389,7 +3386,7 @@ dependencies = [ [[package]] name = "nostr-relay-pool" version = "0.39.0" -source = "git+https://github.com/rust-nostr/nostr#16ad5e1190733f6e20d84891165e54d30f917e6d" +source = "git+https://github.com/reyamir/nostr?branch=feat%2Fimprove-nip17#82c0f9e43519e66503491935924b5d9dc29d1dff" dependencies = [ "async-utility", "async-wsocket", @@ -3406,7 +3403,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.39.0" -source = "git+https://github.com/rust-nostr/nostr#16ad5e1190733f6e20d84891165e54d30f917e6d" +source = "git+https://github.com/reyamir/nostr?branch=feat%2Fimprove-nip17#82c0f9e43519e66503491935924b5d9dc29d1dff" dependencies = [ "async-utility", "nostr", @@ -5244,14 +5241,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" -[[package]] -name = "state" -version = "0.0.0" -dependencies = [ - "dirs 5.0.1", - "nostr-sdk", -] - [[package]] name = "static_assertions" version = "1.1.0" @@ -6288,6 +6277,12 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -6513,6 +6508,17 @@ dependencies = [ "rustix", ] +[[package]] +name = "whoami" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" +dependencies = [ + "redox_syscall", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 6c47027..11282a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,9 +11,9 @@ gpui = { git = "https://github.com/zed-industries/zed" } reqwest_client = { git = "https://github.com/zed-industries/zed" } # Nostr -nostr-relay-builder = { git = "https://github.com/rust-nostr/nostr" } -nostr-connect = { git = "https://github.com/rust-nostr/nostr" } -nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ +nostr-relay-builder = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17" } +nostr-connect = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17" } +nostr-sdk = { git = "https://github.com/reyamir/nostr", branch = "feat/improve-nip17", features = [ "lmdb", "nip96", "nip59", diff --git a/crates/account/Cargo.toml b/crates/account/Cargo.toml deleted file mode 100644 index f9f0b7a..0000000 --- a/crates/account/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "account" -version = "0.0.0" -edition = "2021" -publish = false - -[dependencies] -common = { path = "../common" } -state = { path = "../state" } - -gpui.workspace = true -nostr-sdk.workspace = true -anyhow.workspace = true -smol.workspace = true -oneshot.workspace = true -log.workspace = true diff --git a/crates/account/src/lib.rs b/crates/account/src/lib.rs deleted file mode 100644 index d108990..0000000 --- a/crates/account/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod registry; diff --git a/crates/account/src/registry.rs b/crates/account/src/registry.rs deleted file mode 100644 index 11406f3..0000000 --- a/crates/account/src/registry.rs +++ /dev/null @@ -1,148 +0,0 @@ -use anyhow::{anyhow, Error}; -use common::{ - constants::{ALL_MESSAGES_SUB_ID, NEW_MESSAGE_SUB_ID}, - profile::NostrProfile, -}; -use gpui::{App, AppContext, AsyncApp, Context, Entity, Global, Task}; -use nostr_sdk::prelude::*; -use state::get_client; -use std::{sync::Arc, time::Duration}; - -struct GlobalAccount(Entity); - -impl Global for GlobalAccount {} - -#[derive(Debug, Clone)] -pub struct Account { - profile: NostrProfile, -} - -impl Account { - pub fn global(cx: &App) -> Option> { - cx.try_global::() - .map(|model| model.0.clone()) - } - - pub fn set_global(account: Entity, cx: &mut App) { - cx.set_global(GlobalAccount(account)); - } - - pub fn login(signer: Arc, cx: &AsyncApp) -> Task> { - let client = get_client(); - - let task: Task> = cx.background_spawn(async move { - // Update nostr signer - _ = client.set_signer(signer).await; - - // Verify nostr signer and get public key - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - let metadata = client - .fetch_metadata(public_key, Duration::from_secs(2)) - .await - .unwrap_or_default(); - - Ok(NostrProfile::new(public_key, metadata)) - }); - - cx.spawn(|cx| async move { - match task.await { - Ok(profile) => { - cx.update(|cx| { - let this = cx.new(|cx| { - let this = Self { profile }; - // Run initial sync data for this account - this.sync(cx); - this - }); - - Self::set_global(this, cx) - }) - } - Err(e) => Err(anyhow!("Login failed: {}", e)), - } - }) - } - - pub fn get(&self) -> &NostrProfile { - &self.profile - } - - pub fn verify_inbox_relays(&self, cx: &App) -> Task, Error>> { - let client = get_client(); - let public_key = self.profile.public_key(); - - cx.background_spawn(async move { - let filter = Filter::new() - .kind(Kind::InboxRelays) - .author(public_key) - .limit(1); - - let events = client.database().query(filter).await?; - - if let Some(event) = events.first_owned() { - let relays = event - .tags - .filter_standardized(TagKind::Relay) - .filter_map(|t| match t { - TagStandard::Relay(url) => Some(url.to_string()), - _ => None, - }) - .collect::>(); - - Ok(relays) - } else { - Err(anyhow!("Not found")) - } - }) - } - - fn sync(&self, cx: &mut Context) { - let client = get_client(); - let public_key = self.profile.public_key(); - - cx.background_spawn(async move { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - - // Get contact list - let contact_list = Filter::new() - .kind(Kind::ContactList) - .author(public_key) - .limit(1); - - if let Err(e) = client.subscribe(contact_list, Some(opts)).await { - log::error!("Failed to get contact list: {}", e); - } - - // Create a filter to continuously receive new user's data. - let data = Filter::new() - .kinds(vec![Kind::Metadata, Kind::InboxRelays, Kind::RelayList]) - .author(public_key) - .since(Timestamp::now()); - - if let Err(e) = client.subscribe(data, None).await { - log::error!("Failed to subscribe to user data: {}", e); - } - - // Create a filter for getting all gift wrapped events send to current user - let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); - let sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID); - - if let Err(e) = client - .subscribe_with_id(sub_id, filter.clone(), Some(opts)) - .await - { - log::error!("Failed to subscribe to all messages: {}", e); - } - - // Create a filter to continuously receive new messages. - let new_filter = filter.limit(0); - let sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID); - - if let Err(e) = client.subscribe_with_id(sub_id, new_filter, None).await { - log::error!("Failed to subscribe to new messages: {}", e); - } - }) - .detach(); - } -} diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 054f133..343c404 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -11,9 +11,8 @@ path = "src/main.rs" [dependencies] ui = { path = "../ui" } common = { path = "../common" } -state = { path = "../state" } +global = { path = "../global" } chats = { path = "../chats" } -account = { path = "../account" } gpui.workspace = true reqwest_client.workspace = true diff --git a/crates/app/src/device.rs b/crates/app/src/device.rs new file mode 100644 index 0000000..db324c8 --- /dev/null +++ b/crates/app/src/device.rs @@ -0,0 +1,707 @@ +use std::time::Duration; + +use anyhow::{anyhow, Context as AnyContext, Error}; +use common::profile::NostrProfile; +use global::{ + constants::{ + ALL_MESSAGES_SUB_ID, CLIENT_KEYRING, DEVICE_ANNOUNCEMENT_KIND, DEVICE_REQUEST_KIND, + DEVICE_RESPONSE_KIND, MASTER_KEYRING, NEW_MESSAGE_SUB_ID, + }, + get_app_name, get_client, get_device_name, set_device_keys, +}; +use gpui::{ + div, px, relative, App, AppContext, AsyncApp, Context, Entity, Global, ParentElement, Styled, + Task, Window, +}; +use nostr_sdk::prelude::*; +use smallvec::SmallVec; +use ui::{ + button::{Button, ButtonRounded, ButtonVariants}, + indicator::Indicator, + notification::Notification, + theme::{scale::ColorScaleStep, ActiveTheme}, + ContextModal, Root, Sizable, StyledExt, +}; + +use crate::views::{app, onboarding, relays}; + +struct GlobalDevice(Entity); + +impl Global for GlobalDevice {} + +/// Current Device (Client) +/// +/// NIP-4e: +#[derive(Debug)] +pub struct Device { + /// Profile (Metadata) of current user + profile: Option, + /// Client Keys + client_keys: Keys, +} + +pub fn init(window: &mut Window, cx: &App) { + // Initialize client keys + let read_keys = cx.read_credentials(CLIENT_KEYRING); + let window_handle = window.window_handle(); + + cx.spawn(|cx| async move { + let client_keys = if let Ok(Some((_, secret))) = read_keys.await { + let secret_key = SecretKey::from_slice(&secret).unwrap(); + + Keys::new(secret_key) + } else { + // Generate new keys and save them to keyring + let keys = Keys::generate(); + + if let Ok(write_keys) = cx.update(|cx| { + cx.write_credentials( + CLIENT_KEYRING, + keys.public_key.to_hex().as_str(), + keys.secret_key().as_secret_bytes(), + ) + }) { + _ = write_keys.await; + }; + + keys + }; + + cx.update(|cx| { + let entity = cx.new(|_| Device { + profile: None, + client_keys, + }); + + window_handle + .update(cx, |_, window, cx| { + // Open the onboarding view + Root::update(window, cx, |this, window, cx| { + this.replace_view(onboarding::init(window, cx).into()); + cx.notify(); + }); + + // Observe login behavior + window + .observe(&entity, cx, |this, window, cx| { + this.update(cx, |this, cx| { + this.on_login(window, cx); + }); + }) + .detach(); + }) + .ok(); + + Device::set_global(entity, cx) + }) + .ok(); + }) + .detach(); +} + +impl Device { + pub fn global(cx: &App) -> Option> { + cx.try_global::().map(|model| model.0.clone()) + } + + pub fn set_global(device: Entity, cx: &mut App) { + cx.set_global(GlobalDevice(device)); + } + + pub fn profile(&self) -> Option<&NostrProfile> { + self.profile.as_ref() + } + + pub fn set_profile(&mut self, profile: NostrProfile, cx: &mut Context) { + self.profile = Some(profile); + cx.notify(); + } + + /// Login and set user signer + pub fn login(&self, signer: T, cx: &mut Context) -> Task> + where + T: NostrSigner + 'static, + { + let client = get_client(); + + // Set the user's signer as the main signer + let login: Task> = cx.background_spawn(async move { + // Use user's signer for main signer + _ = client.set_signer(signer).await; + + // Verify nostr signer and get public key + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + + // Fetch user's metadata + let metadata = client + .fetch_metadata(public_key, Duration::from_secs(2)) + .await + .unwrap_or_default(); + + // Get user's inbox relays + let filter = Filter::new() + .kind(Kind::InboxRelays) + .author(public_key) + .limit(1); + + let relays = if let Some(event) = client + .fetch_events(filter, Duration::from_secs(2)) + .await? + .first_owned() + { + let relays = event + .tags + .filter_standardized(TagKind::Relay) + .filter_map(|t| { + if let TagStandard::Relay(url) = t { + Some(url.to_owned()) + } else { + None + } + }) + .collect::>(); + + Some(relays) + } else { + None + }; + + let profile = NostrProfile::new(public_key, metadata).relays(relays); + + Ok(profile) + }); + + cx.spawn(|this, cx| async move { + match login.await { + Ok(user) => { + cx.update(|cx| { + this.update(cx, |this, cx| { + this.profile = Some(user); + cx.notify(); + }) + .ok(); + }) + .ok(); + + Ok(()) + } + Err(e) => Err(e), + } + }) + } + + fn on_login(&mut self, window: &mut Window, cx: &mut Context) { + let Some(profile) = self.profile.as_ref() else { + // User not logged in, render the Onboarding View + Root::update(window, cx, |this, window, cx| { + this.replace_view(onboarding::init(window, cx).into()); + cx.notify(); + }); + + return; + }; + + // Replace the Onboarding View with the Dock View + Root::update(window, cx, |this, window, cx| { + this.replace_view(app::init(window, cx).into()); + cx.notify(); + }); + + let pubkey = profile.public_key; + let client_keys = self.client_keys.clone(); + + // User's messaging relays not found + if profile.messaging_relays.is_none() { + cx.spawn_in(window, |this, mut cx| async move { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + this.render_setup_relays(window, cx); + }) + .ok(); + }) + .ok(); + }) + .detach(); + + return; + }; + + cx.spawn_in(window, |this, mut cx| async move { + // Initialize subscription for current user + _ = Device::subscribe(pubkey, &cx).await; + + // Initialize master keys for current user + if let Ok(Some(keys)) = Device::fetch_master_keys(pubkey, &cx).await { + set_device_keys(keys.clone()).await; + + if let Ok(task) = cx.update(|_, cx| { + cx.write_credentials( + MASTER_KEYRING, + keys.public_key().to_hex().as_str(), + keys.secret_key().as_secret_bytes(), + ) + }) { + _ = task.await; + } + + if let Ok(event) = Device::fetch_request(pubkey, &cx).await { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + this.handle_request(event, window, cx); + }) + .ok(); + }) + .ok(); + } + + cx.background_spawn(async move { + let client = get_client(); + let filter = Filter::new() + .kind(Kind::Custom(DEVICE_REQUEST_KIND)) + .author(pubkey) + .since(Timestamp::now()); + + _ = client.subscribe(filter, None).await; + }) + .await; + } else { + // Send request for master keys + if Device::request_keys(pubkey, client_keys, &cx).await.is_ok() { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + this.render_waiting_modal(window, cx); + }) + .ok(); + }) + .ok(); + } + } + }) + .detach(); + } + + /// Receive device keys approval from other Nostr client, + /// then process and update device keys. + pub fn handle_response(&self, event: Event, window: &mut Window, cx: &Context) { + let local_signer = self.client_keys.clone().into_nostr_signer(); + + let task = cx.background_spawn(async move { + if let Some(public_key) = event.tags.public_keys().copied().last() { + let secret = local_signer + .nip44_decrypt(&public_key, &event.content) + .await?; + + let keys = Keys::parse(&secret)?; + + // Update global state with new device keys + set_device_keys(keys).await; + log::info!("Received device keys from other client"); + + Ok(()) + } else { + Err(anyhow!("Failed to retrieve device key")) + } + }); + + cx.spawn_in(window, |_, mut cx| async move { + if let Err(e) = task.await { + cx.update(|window, cx| { + window.push_notification(Notification::error(e.to_string()), cx); + }) + .ok(); + } else { + cx.update(|window, cx| { + window.push_notification( + Notification::success("Device Keys request has been approved"), + cx, + ); + }) + .ok(); + } + }) + .detach(); + } + + /// Received device keys request from other Nostr client, + /// then process the request and send approval response. + pub fn handle_request(&self, event: Event, window: &mut Window, cx: &mut Context) { + let Some(public_key) = event + .tags + .find(TagKind::custom("pubkey")) + .and_then(|tag| tag.content()) + .and_then(|content| PublicKey::parse(content).ok()) + else { + return; + }; + + let client = get_client(); + let read_keys = cx.read_credentials(MASTER_KEYRING); + let local_signer = self.client_keys.clone().into_nostr_signer(); + + let device_name = event + .tags + .find(TagKind::Client) + .and_then(|tag| tag.content()) + .unwrap_or("Other Device") + .to_owned(); + + let response = window.prompt( + gpui::PromptLevel::Info, + "Requesting Device Keys", + Some( + format!( + "{} is requesting shared device keys stored in this device", + device_name + ) + .as_str(), + ), + &["Approve", "Deny"], + cx, + ); + + cx.spawn_in(window, |_, cx| async move { + match response.await { + Ok(0) => { + if let Ok(Some((_, secret))) = read_keys.await { + let local_pubkey = local_signer.get_public_key().await?; + + // Get device's secret key + let device_secret = SecretKey::from_slice(&secret)?; + + // Encrypt device's secret key by using NIP-44 + let content = local_signer + .nip44_encrypt(&public_key, &device_secret.to_secret_hex()) + .await?; + + // Create pubkey tag for other device (lowercase p) + let other_tag = Tag::public_key(public_key); + + // Create pubkey tag for this device (uppercase P) + let local_tag = Tag::custom( + TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::P)), + vec![local_pubkey.to_hex()], + ); + + // Create event builder + let kind = Kind::Custom(DEVICE_RESPONSE_KIND); + let tags = vec![other_tag, local_tag]; + let builder = EventBuilder::new(kind, content).tags(tags); + + cx.background_spawn(async move { + if let Err(err) = client.send_event_builder(builder).await { + log::error!("Failed to send device keys to other client: {}", err); + } else { + log::info!("Sent device keys to other client"); + } + }) + .await; + + Ok(()) + } else { + Err(anyhow!("Device Keys not found")) + } + } + _ => Ok(()), + } + }) + .detach(); + } + + /// Show setup relays modal + /// + /// NIP-17: + pub fn render_setup_relays(&mut self, window: &mut Window, cx: &mut Context) { + let relays = relays::init(window, cx); + + window.open_modal(cx, move |this, window, cx| { + let is_loading = relays.read(cx).loading(); + + this.keyboard(false) + .closable(false) + .width(px(420.)) + .title("Your Messaging Relays are not configured") + .child(relays.clone()) + .footer( + div() + .p_2() + .border_t_1() + .border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE)) + .child( + Button::new("update_inbox_relays_btn") + .label("Update") + .primary() + .bold() + .rounded(ButtonRounded::Large) + .w_full() + .loading(is_loading) + .on_click(window.listener_for(&relays, |this, _, window, cx| { + this.update(window, cx); + })), + ), + ) + }); + } + + /// Show waiting modal + /// + /// NIP-4e: + pub fn render_waiting_modal(&mut self, window: &mut Window, cx: &mut Context) { + window.open_modal(cx, move |this, _window, cx| { + let msg = format!( + "Please open {} and approve sharing device keys request.", + get_device_name() + ); + + this.keyboard(false) + .closable(false) + .width(px(420.)) + .child( + div() + .flex() + .items_center() + .justify_center() + .size_full() + .p_4() + .child( + div() + .flex() + .flex_col() + .items_center() + .justify_center() + .size_full() + .child( + div() + .flex() + .flex_col() + .text_sm() + .child( + div() + .font_semibold() + .child("You're using a new device."), + ) + .child( + div() + .text_color( + cx.theme() + .base + .step(cx, ColorScaleStep::ELEVEN), + ) + .line_height(relative(1.3)) + .child(msg), + ), + ), + ), + ) + .footer( + div() + .p_4() + .border_t_1() + .border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE)) + .w_full() + .flex() + .gap_2() + .items_center() + .justify_center() + .text_xs() + .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) + .child(Indicator::new().small()) + .child("Waiting for approval ..."), + ) + }); + } + + /// Fetch the latest request from the other Nostr client + /// + /// NIP-4e: + fn fetch_request(user: PublicKey, cx: &AsyncApp) -> Task> { + let client = get_client(); + let filter = Filter::new() + .kind(Kind::Custom(DEVICE_REQUEST_KIND)) + .author(user) + .limit(1); + + cx.background_spawn(async move { + let events = client.fetch_events(filter, Duration::from_secs(2)).await?; + + if let Some(event) = events.first_owned() { + Ok(event) + } else { + Err(anyhow!("No request found")) + } + }) + } + + /// Send a request to ask for device keys from the other Nostr client + /// + /// NIP-4e: + fn request_keys(user: PublicKey, client_keys: Keys, cx: &AsyncApp) -> Task> { + let client = get_client(); + let app_name = get_app_name(); + + let kind = Kind::Custom(DEVICE_REQUEST_KIND); + let client_tag = Tag::client(app_name); + let pubkey_tag = Tag::custom( + TagKind::custom("pubkey"), + vec![client_keys.public_key().to_hex()], + ); + + // Create a request event builder + let builder = EventBuilder::new(kind, "").tags(vec![client_tag, pubkey_tag]); + + cx.background_spawn(async move { + log::info!("Sent a request to ask for device keys from the other Nostr client"); + + if let Err(e) = client.send_event_builder(builder).await { + log::error!("Failed to send device keys request: {}", e); + } + + log::info!("Waiting for response..."); + + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + let filter = Filter::new() + .kind(Kind::Custom(DEVICE_RESPONSE_KIND)) + .author(user); + + // Getting all previous approvals + client.subscribe(filter.clone(), Some(opts)).await?; + + // Continously receive the request approval + client + .subscribe(filter.since(Timestamp::now()), None) + .await?; + + Ok(()) + }) + } + + /// Get the master keys for current user + /// + /// NIP-4e: + #[allow(clippy::type_complexity)] + fn fetch_master_keys(user: PublicKey, cx: &AsyncApp) -> Task, Error>> { + let client = get_client(); + + let kind = Kind::Custom(DEVICE_ANNOUNCEMENT_KIND); + let filter = Filter::new().kind(kind).author(user).limit(1); + + // Fetch device announcement events + let fetch_announcement = cx.background_spawn(async move { + if let Some(event) = client.database().query(filter).await?.first_owned() { + println!("event: {:?}", event); + Ok(event) + } else { + Err(anyhow!("Device Announcement not found.")) + } + }); + + cx.spawn(|cx| async move { + let Ok(task) = cx.update(|cx| cx.read_credentials(MASTER_KEYRING)) else { + return Err(anyhow!("Failed to read credentials")); + }; + + let secret = task.await; + + if let Ok(event) = fetch_announcement.await { + if let Ok(Some((_, secret))) = secret { + let secret_key = SecretKey::from_slice(&secret)?; + let keys = Keys::new(secret_key); + let device_pubkey = keys.public_key(); + + log::info!("Device's Public Key: {:?}", device_pubkey); + + let n_tag = event.tags.find(TagKind::custom("n")).context("Not found")?; + let content = n_tag.content().context("Not found")?; + let target_pubkey = PublicKey::parse(content)?; + + // If device public key matches announcement public key, re-appoint as master + if device_pubkey == target_pubkey { + log::info!("Re-appointing this device as master"); + return Ok(Some(keys)); + } + } + + Ok(None) + } else { + log::info!("Device announcement is not found, appoint this device as master"); + + let app_name = get_app_name(); + let keys = Keys::generate(); + let kind = Kind::Custom(DEVICE_ANNOUNCEMENT_KIND); + let client_tag = Tag::client(app_name); + let pubkey_tag = + Tag::custom(TagKind::custom("n"), vec![keys.public_key().to_hex()]); + + let _task: Result<(), Error> = cx + .background_spawn(async move { + let signer = client.signer().await?; + let event = EventBuilder::new(kind, "") + .tags(vec![client_tag, pubkey_tag]) + .sign(&signer) + .await?; + + if let Err(e) = client.send_event(&event).await { + log::error!("Failed to send device announcement: {}", e); + } else { + log::info!("Device announcement sent"); + } + + Ok(()) + }) + .await; + + Ok(Some(keys)) + } + }) + } + + /// Initialize subscription for current user + fn subscribe(user: PublicKey, cx: &AsyncApp) -> Task> { + let client = get_client(); + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + + let device_kind = Kind::Custom(DEVICE_ANNOUNCEMENT_KIND); + + // Create a device announcement filter + let device = Filter::new().kind(device_kind).author(user).limit(1); + + // Create a contact list filter + let contacts = Filter::new().kind(Kind::ContactList).author(user).limit(1); + + // Create a user's data filter + let data = Filter::new() + .author(user) + .since(Timestamp::now()) + .kinds(vec![ + Kind::Metadata, + Kind::InboxRelays, + Kind::RelayList, + device_kind, + ]); + + // Create a filter for getting all gift wrapped events send to current user + let msg = Filter::new().kind(Kind::GiftWrap).pubkey(user); + + // Create a filter to continuously receive new messages. + let new_msg = Filter::new().kind(Kind::GiftWrap).pubkey(user).limit(0); + + cx.background_spawn(async move { + // Only subscribe to the latest device announcement + client.subscribe(device, Some(opts)).await?; + + // Only subscribe to the latest contact list + client.subscribe(contacts, Some(opts)).await?; + + // Continuously receive new user's data since now + client.subscribe(data, None).await?; + + let sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID); + client.subscribe_with_id(sub_id, msg, Some(opts)).await?; + + let sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID); + client.subscribe_with_id(sub_id, new_msg, None).await?; + + Ok(()) + }) + } +} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 48269bc..46a4b12 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -1,11 +1,17 @@ +use anyhow::anyhow; use asset::Assets; use chats::registry::ChatRegistry; -use common::constants::{ - ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, BOOTSTRAP_RELAYS, KEYRING_SERVICE, NEW_MESSAGE_SUB_ID, -}; +use device::Device; use futures::{select, FutureExt}; +use global::{ + constants::{ + ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, BOOTSTRAP_RELAYS, DEVICE_ANNOUNCEMENT_KIND, + DEVICE_REQUEST_KIND, DEVICE_RESPONSE_KIND, NEW_MESSAGE_SUB_ID, + }, + get_client, get_device_keys, set_device_name, +}; use gpui::{ - actions, px, size, App, AppContext, Application, AsyncApp, Bounds, KeyBinding, Menu, MenuItem, + actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, WindowBounds, WindowKind, WindowOptions, }; #[cfg(not(target_os = "linux"))] @@ -13,32 +19,33 @@ use gpui::{point, SharedString, TitlebarOptions}; #[cfg(target_os = "linux")] use gpui::{WindowBackgroundAppearance, WindowDecorations}; use nostr_sdk::{ - pool::prelude::ReqExitPolicy, Client, Event, Filter, Keys, Kind, PublicKey, RelayMessage, - RelayPoolNotification, SubscribeAutoCloseOptions, SubscriptionId, + nips::nip59::UnwrappedGift, pool::prelude::ReqExitPolicy, Event, Filter, Keys, Kind, PublicKey, + RelayMessage, RelayPoolNotification, SubscribeAutoCloseOptions, SubscriptionId, TagKind, }; use smol::Timer; -use state::get_client; use std::{collections::HashSet, mem, sync::Arc, time::Duration}; -use ui::{theme::Theme, Root}; -use views::{app, onboarding}; +use ui::Root; +use views::startup; mod asset; +mod device; mod views; actions!(coop, [Quit]); -#[derive(Clone)] +#[derive(Debug)] enum Signal { /// Receive event Event(Event), + /// Receive request master key event + RequestMasterKey(Event), + /// Receive approve master key event + ReceiveMasterKey(Event), /// Receive EOSE Eose, } fn main() { - // Fix crash on startup - // TODO: why this is needed? - _ = rustls::crypto::ring::default_provider().install_default(); // Enable logging tracing_subscriber::fmt::init(); @@ -56,11 +63,17 @@ fn main() { // Connect to default relays app.background_executor() .spawn(async { - for relay in BOOTSTRAP_RELAYS.iter() { - _ = client.add_relay(*relay).await; + // Fix crash on startup + // TODO: why this is needed? + _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + for relay in BOOTSTRAP_RELAYS.into_iter() { + _ = client.add_relay(relay).await; } + _ = client.add_discovery_relay("wss://relaydiscovery.com").await; _ = client.add_discovery_relay("wss://user.kindpag.es").await; + _ = client.connect().await }) .detach(); @@ -69,7 +82,7 @@ fn main() { app.background_executor() .spawn(async move { const BATCH_SIZE: usize = 20; - const BATCH_TIMEOUT: Duration = Duration::from_millis(200); + const BATCH_TIMEOUT: Duration = Duration::from_millis(500); let mut batch: HashSet = HashSet::new(); @@ -82,7 +95,7 @@ fn main() { Ok(keys) => { batch.extend(keys); if batch.len() >= BATCH_SIZE { - sync_metadata(client, mem::take(&mut batch)).await; + handle_metadata(mem::take(&mut batch)).await; } } Err(_) => break, @@ -90,7 +103,7 @@ fn main() { } _ = timeout => { if !batch.is_empty() { - sync_metadata(client, mem::take(&mut batch)).await; + handle_metadata(mem::take(&mut batch)).await; } } } @@ -115,13 +128,12 @@ fn main() { } => { match event.kind { Kind::GiftWrap => { - if let Ok(gift) = client.unwrap_gift_wrap(&event).await { - let mut pubkeys = vec![]; - + if let Ok(gift) = handle_gift_wrap(&event).await { // Sign the rumor with the generated keys, // this event will be used for internal only, // and NEVER send to relays. if let Ok(event) = gift.rumor.sign_with_keys(&rng_keys) { + let mut pubkeys = vec![]; pubkeys.extend(event.tags.public_keys()); pubkeys.push(event.pubkey); @@ -133,23 +145,11 @@ fn main() { } // Send all pubkeys to the batch - if let Err(e) = batch_tx.send(pubkeys).await { - log::error!( - "Failed to send pubkeys to batch: {}", - e - ) - } + _ = batch_tx.send(pubkeys).await; // Send this event to the GPUI if new_id == *subscription_id { - if let Err(e) = - event_tx.send(Signal::Event(event)).await - { - log::error!( - "Failed to send event to GPUI: {}", - e - ) - } + _ = event_tx.send(Signal::Event(event)).await; } } } @@ -158,7 +158,32 @@ fn main() { let pubkeys = event.tags.public_keys().copied().collect::>(); - sync_metadata(client, pubkeys).await; + handle_metadata(pubkeys).await; + } + Kind::Custom(DEVICE_REQUEST_KIND) => { + log::info!("Received device keys request"); + + _ = event_tx + .send(Signal::RequestMasterKey(event.into_owned())) + .await; + } + Kind::Custom(DEVICE_RESPONSE_KIND) => { + log::info!("Received device keys approval"); + + _ = event_tx + .send(Signal::ReceiveMasterKey(event.into_owned())) + .await; + } + Kind::Custom(DEVICE_ANNOUNCEMENT_KIND) => { + log::info!("Device announcement received"); + + if let Some(tag) = event + .tags + .find(TagKind::custom("client")) + .and_then(|tag| tag.content()) + { + set_device_name(tag).await; + } } _ => {} } @@ -177,62 +202,24 @@ fn main() { }) .detach(); - // Handle re-open window - app.on_reopen(move |cx| { - let client = get_client(); - let (tx, rx) = oneshot::channel::(); - - cx.background_spawn(async move { - let is_login = client.signer().await.is_ok(); - _ = tx.send(is_login); - }) - .detach(); - - cx.spawn(|mut cx| async move { - if let Ok(is_login) = rx.await { - _ = restore_window(is_login, &mut cx).await; - } - }) - .detach(); - }); - app.run(move |cx| { - // Initialize chat global state - chats::registry::init(cx); - // Initialize components - ui::init(cx); // Bring the app to the foreground cx.activate(true); + // Register the `quit` function cx.on_action(quit); + // Register the `quit` function with CMD+Q cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); + // Set menu items cx.set_menus(vec![Menu { name: "Coop".into(), items: vec![MenuItem::action("Quit", Quit)], }]); - // Spawn a task to handle events from nostr channel - cx.spawn(|cx| async move { - while let Ok(signal) = event_rx.recv().await { - cx.update(|cx| { - if let Some(chats) = ChatRegistry::global(cx) { - match signal { - Signal::Eose => chats.update(cx, |this, cx| this.load_chat_rooms(cx)), - Signal::Event(event) => { - chats.update(cx, |this, cx| this.push_message(event, cx)) - } - }; - } - }) - .ok(); - } - }) - .detach(); - // Set up the window options - let window_opts = WindowOptions { + let opts = WindowOptions { #[cfg(not(target_os = "linux"))] titlebar: Some(TitlebarOptions { title: Some(SharedString::new_static(APP_NAME)), @@ -249,126 +236,103 @@ fn main() { #[cfg(target_os = "linux")] window_decorations: Some(WindowDecorations::Client), kind: WindowKind::Normal, + app_id: Some(APP_ID.to_owned()), ..Default::default() }; - // Create a task to read credentials from the keyring service - let task = cx.read_credentials(KEYRING_SERVICE); - let (tx, rx) = oneshot::channel::(); + // Open a window with default options + cx.open_window(opts, |window, cx| { + // Initialize components + ui::init(cx); - // Read credential in OS Keyring - cx.background_spawn(async { - let is_ready = if let Ok(Some((_, secret))) = task.await { - let result = async { - let secret_hex = String::from_utf8(secret)?; - let keys = Keys::parse(&secret_hex)?; + // Initialize chat global state + chats::registry::init(cx); - // Update nostr signer - client.set_signer(keys).await; + // Initialize device + device::init(window, cx); - Ok::<_, anyhow::Error>(true) - } - .await; + cx.new(|cx| { + let root = Root::new(startup::init(window, cx).into(), window, cx); - result.is_ok() - } else { - false - }; + // Spawn a task to handle events from nostr channel + cx.spawn_in(window, |_, mut cx| async move { + while let Ok(signal) = event_rx.recv().await { + cx.update(|window, cx| { + match signal { + Signal::Eose => { + if let Some(chats) = ChatRegistry::global(cx) { + chats.update(cx, |this, cx| this.load_chat_rooms(cx)) + } + } + Signal::Event(event) => { + if let Some(chats) = ChatRegistry::global(cx) { + chats.update(cx, |this, cx| this.push_message(event, cx)) + } + } + Signal::ReceiveMasterKey(event) => { + if let Some(device) = Device::global(cx) { + device.update(cx, |this, cx| { + this.handle_response(event, window, cx); + }); + } + } + Signal::RequestMasterKey(event) => { + if let Some(device) = Device::global(cx) { + device.update(cx, |this, cx| { + this.handle_request(event, window, cx); + }); + } + } + }; + }) + .ok(); + } + }) + .detach(); - _ = tx.send(is_ready) + root + }) }) - .detach(); - - cx.spawn(|cx| async move { - if let Ok(is_ready) = rx.await { - if is_ready { - // Open a App window - cx.open_window(window_opts, |window, cx| { - cx.new(|cx| Root::new(app::init(window, cx).into(), window, cx)) - }) - .expect("Failed to open window"); - } else { - // Open a Onboarding window - cx.open_window(window_opts, |window, cx| { - cx.new(|cx| Root::new(onboarding::init(window, cx).into(), window, cx)) - }) - .expect("Failed to open window"); - } - } - }) - .detach(); + .expect("Failed to open window. Please restart the application."); }); } -async fn sync_metadata(client: &Client, buffer: HashSet) { +async fn handle_gift_wrap(gift_wrap: &Event) -> Result { + let client = get_client(); + + if let Some(device) = get_device_keys().await { + // Try to unwrap with the device keys first + match UnwrappedGift::from_gift_wrap(&device, gift_wrap).await { + Ok(event) => Ok(event), + Err(_) => { + // Try to unwrap again with the user's signer + let signer = client.signer().await?; + let event = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await?; + Ok(event) + } + } + } else { + Err(anyhow!("Signer not found")) + } +} + +async fn handle_metadata(buffer: HashSet) { + let client = get_client(); + let opts = SubscribeAutoCloseOptions::default() .exit_policy(ReqExitPolicy::ExitOnEOSE) .idle_timeout(Some(Duration::from_secs(2))); let filter = Filter::new() .authors(buffer.iter().cloned()) - .kind(Kind::Metadata) - .limit(buffer.len()); + .limit(buffer.len() * 2) + .kinds(vec![Kind::Metadata, Kind::Custom(DEVICE_ANNOUNCEMENT_KIND)]); if let Err(e) = client.subscribe(filter, Some(opts)).await { log::error!("Failed to sync metadata: {e}"); } } -async fn restore_window(is_login: bool, cx: &mut AsyncApp) -> anyhow::Result<()> { - let opts = cx - .update(|cx| WindowOptions { - #[cfg(not(target_os = "linux"))] - titlebar: Some(TitlebarOptions { - title: Some(SharedString::new_static(APP_NAME)), - traffic_light_position: Some(point(px(9.0), px(9.0))), - appears_transparent: true, - }), - window_bounds: Some(WindowBounds::Windowed(Bounds::centered( - None, - size(px(900.0), px(680.0)), - cx, - ))), - #[cfg(target_os = "linux")] - window_background: WindowBackgroundAppearance::Transparent, - #[cfg(target_os = "linux")] - window_decorations: Some(WindowDecorations::Client), - kind: WindowKind::Normal, - ..Default::default() - }) - .expect("Failed to set window options."); - - if is_login { - _ = cx.open_window(opts, |window, cx| { - window.set_window_title(APP_NAME); - window.set_app_id(APP_ID); - - #[cfg(not(target_os = "linux"))] - window - .observe_window_appearance(|window, cx| { - Theme::sync_system_appearance(Some(window), cx); - }) - .detach(); - - cx.new(|cx| Root::new(app::init(window, cx).into(), window, cx)) - }); - } else { - _ = cx.open_window(opts, |window, cx| { - window.set_window_title(APP_NAME); - window.set_app_id(APP_ID); - window - .observe_window_appearance(|window, cx| { - Theme::sync_system_appearance(Some(window), cx); - }) - .detach(); - - cx.new(|cx| Root::new(onboarding::init(window, cx).into(), window, cx)) - }); - }; - - Ok(()) -} - fn quit(_: &Quit, cx: &mut App) { log::info!("Gracefully quitting the application . . ."); cx.quit(); diff --git a/crates/app/src/views/app.rs b/crates/app/src/views/app.rs index 63368a2..c627d30 100644 --- a/crates/app/src/views/app.rs +++ b/crates/app/src/views/app.rs @@ -1,11 +1,10 @@ -use account::registry::Account; +use global::get_client; use gpui::{ actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled, StyledImage, Window, }; use serde::Deserialize; -use state::get_client; use std::sync::Arc; use ui::{ button::{Button, ButtonRounded, ButtonVariants}, @@ -15,7 +14,8 @@ use ui::{ ContextModal, Icon, IconName, Root, Sizable, TitleBar, }; -use super::{chat, contacts, onboarding, profile, relays::Relays, settings, sidebar, welcome}; +use super::{chat, contacts, onboarding, profile, relays, settings, sidebar, welcome}; +use crate::device::Device; #[derive(Clone, PartialEq, Eq, Deserialize)] pub enum PanelKind { @@ -39,6 +39,7 @@ impl AddPanel { // Dock actions impl_internal_actions!(dock, [AddPanel]); + // Account actions actions!(account, [Logout]); @@ -47,7 +48,6 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { } pub struct AppView { - relays: Entity>>, dock: Entity, } @@ -82,56 +82,81 @@ impl AppView { view.set_center(center_panel, window, cx); }); - cx.new(|cx| { - let relays = cx.new(|_| None); - let this = Self { relays, dock }; - - // Check user's messaging relays and determine user is ready for NIP17 or not. - // If not, show the setup modal and instruct user setup inbox relays - this.verify_user_relays(window, cx); - - this - }) + cx.new(|_| Self { dock }) } - fn verify_user_relays(&self, window: &mut Window, cx: &mut Context) { - let Some(model) = Account::global(cx) else { - return; - }; - - let account = model.read(cx); - let task = account.verify_inbox_relays(cx); - let window_handle = window.window_handle(); - - cx.spawn(|this, mut cx| async move { - if let Ok(relays) = task.await { - _ = cx.update(|cx| { - _ = this.update(cx, |this, cx| { - this.relays = cx.new(|_| Some(relays)); - cx.notify(); - }); - }); - } else { - _ = cx.update_window(window_handle, |_, window, cx| { - _ = this.update(cx, |this: &mut Self, cx| { - this.render_setup_relays(window, cx) - }); - }); - } - }) - .detach(); + fn render_mode_btn(&self, cx: &mut Context) -> impl IntoElement { + Button::new("appearance") + .xsmall() + .ghost() + .map(|this| { + if cx.theme().appearance.is_dark() { + this.icon(IconName::Sun) + } else { + this.icon(IconName::Moon) + } + }) + .on_click(cx.listener(|_, _, window, cx| { + if cx.theme().appearance.is_dark() { + Theme::change(Appearance::Light, Some(window), cx); + } else { + Theme::change(Appearance::Dark, Some(window), cx); + } + })) } - fn render_setup_relays(&self, window: &mut Window, cx: &mut Context) { - let relays = cx.new(|cx| Relays::new(None, window, cx)); + fn render_account_btn(&self, cx: &mut Context) -> impl IntoElement { + Button::new("account") + .ghost() + .xsmall() + .reverse() + .icon(Icon::new(IconName::ChevronDownSmall)) + .when_some(Device::global(cx), |this, account| { + this.when_some(account.read(cx).profile(), |this, profile| { + this.child( + img(profile.avatar.clone()) + .size_5() + .rounded_full() + .object_fit(ObjectFit::Cover), + ) + }) + }) + .popup_menu(move |this, _, _cx| { + this.menu( + "Profile", + Box::new(AddPanel::new(PanelKind::Profile, DockPlacement::Right)), + ) + .menu( + "Contacts", + Box::new(AddPanel::new(PanelKind::Contacts, DockPlacement::Right)), + ) + .menu( + "Settings", + Box::new(AddPanel::new(PanelKind::Settings, DockPlacement::Center)), + ) + .separator() + .menu("Change account", Box::new(Logout)) + }) + } + + fn render_relays_btn(&self, cx: &mut Context) -> impl IntoElement { + Button::new("relays") + .xsmall() + .ghost() + .icon(IconName::Relays) + .on_click(cx.listener(|this, _, window, cx| { + this.render_edit_relays(window, cx); + })) + } + + fn render_edit_relays(&self, window: &mut Window, cx: &mut Context) { + let relays = relays::init(window, cx); window.open_modal(cx, move |this, window, cx| { let is_loading = relays.read(cx).loading(); - this.keyboard(false) - .closable(false) - .width(px(420.)) - .title("Your Messaging Relays are not configured") + this.width(px(420.)) + .title("Edit your Messaging Relays") .child(relays.clone()) .footer( div() @@ -154,109 +179,6 @@ impl AppView { }); } - fn render_edit_relay(&self, window: &mut Window, cx: &mut Context) { - let relays = self.relays.read(cx).clone(); - let view = cx.new(|cx| Relays::new(relays, window, cx)); - - window.open_modal(cx, move |this, window, cx| { - let is_loading = view.read(cx).loading(); - - this.width(px(420.)) - .title("Edit your Messaging Relays") - .child(view.clone()) - .footer( - div() - .p_2() - .border_t_1() - .border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE)) - .child( - Button::new("update_inbox_relays_btn") - .label("Update") - .primary() - .bold() - .rounded(ButtonRounded::Large) - .w_full() - .loading(is_loading) - .on_click(window.listener_for(&view, |this, _, window, cx| { - this.update(window, cx); - })), - ), - ) - }); - } - - fn render_appearance_button( - &self, - _window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - Button::new("appearance") - .xsmall() - .ghost() - .map(|this| { - if cx.theme().appearance.is_dark() { - this.icon(IconName::Sun) - } else { - this.icon(IconName::Moon) - } - }) - .on_click(cx.listener(|_, _, window, cx| { - if cx.theme().appearance.is_dark() { - Theme::change(Appearance::Light, Some(window), cx); - } else { - Theme::change(Appearance::Dark, Some(window), cx); - } - })) - } - - fn render_relays_button( - &self, - _window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - Button::new("relays") - .xsmall() - .ghost() - .icon(IconName::Relays) - .on_click(cx.listener(|this, _, window, cx| { - this.render_edit_relay(window, cx); - })) - } - - fn render_account(&self, cx: &mut Context) -> impl IntoElement { - Button::new("account") - .ghost() - .xsmall() - .reverse() - .icon(Icon::new(IconName::ChevronDownSmall)) - .when_some(Account::global(cx), |this, account| { - let profile = account.read(cx).get(); - - this.child( - img(profile.avatar()) - .size_5() - .rounded_full() - .object_fit(ObjectFit::Cover), - ) - }) - .popup_menu(move |this, _, _cx| { - this.menu( - "Profile", - Box::new(AddPanel::new(PanelKind::Profile, DockPlacement::Right)), - ) - .menu( - "Contacts", - Box::new(AddPanel::new(PanelKind::Contacts, DockPlacement::Right)), - ) - .menu( - "Settings", - Box::new(AddPanel::new(PanelKind::Settings, DockPlacement::Center)), - ) - .separator() - .menu("Change account", Box::new(Logout)) - }) - } - fn on_panel_action(&mut self, action: &AddPanel, window: &mut Window, cx: &mut Context) { match &action.panel { PanelKind::Room(id) => { @@ -303,8 +225,9 @@ impl AppView { }) .detach(); - window.replace_root(cx, |window, cx| { - Root::new(onboarding::init(window, cx).into(), window, cx) + Root::update(window, cx, |this, window, cx| { + this.replace_view(onboarding::init(window, cx).into()); + cx.notify(); }); } } @@ -317,29 +240,37 @@ impl Render for AppView { div() .relative() .size_full() - .flex() - .flex_col() - // Main .child( - TitleBar::new() - // Left side - .child(div()) - // Right side + div() + .flex() + .flex_col() + .size_full() + // Title Bar .child( - div() - .flex() - .items_center() - .justify_end() - .gap_2() - .px_2() - .child(self.render_appearance_button(window, cx)) - .child(self.render_relays_button(window, cx)) - .child(self.render_account(cx)), - ), + TitleBar::new() + // Left side + .child(div()) + // Right side + .child( + div() + .flex() + .items_center() + .justify_end() + .gap_2() + .px_2() + .child(self.render_mode_btn(cx)) + .child(self.render_relays_btn(cx)) + .child(self.render_account_btn(cx)), + ), + ) + // Dock + .child(self.dock.clone()), ) - .child(self.dock.clone()) + // Notifications .child(div().absolute().top_8().children(notification_layer)) + // Modals .children(modal_layer) + // Actions .on_action(cx.listener(Self::on_panel_action)) .on_action(cx.listener(Self::on_logout_action)) } diff --git a/crates/app/src/views/chat.rs b/crates/app/src/views/chat.rs index 7efb516..8747867 100644 --- a/crates/app/src/views/chat.rs +++ b/crates/app/src/views/chat.rs @@ -2,11 +2,11 @@ use anyhow::anyhow; use async_utility::task::spawn; use chats::{registry::ChatRegistry, room::Room}; use common::{ - constants::IMAGE_SERVICE, last_seen::LastSeen, profile::NostrProfile, utils::{compare, nip96_upload}, }; +use global::{constants::IMAGE_SERVICE, get_client}; use gpui::{ div, img, list, prelude::FluentBuilder, px, relative, svg, white, AnyElement, App, AppContext, Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement, @@ -17,7 +17,6 @@ use gpui::{ use itertools::Itertools; use nostr_sdk::prelude::*; use smol::fs; -use state::get_client; use std::sync::Arc; use ui::{ button::{Button, ButtonRounded, ButtonVariants}, @@ -63,8 +62,8 @@ impl ParsedMessage { let created_at = LastSeen(created_at).human_readable(); Self { - avatar: profile.avatar(), - display_name: profile.name(), + avatar: profile.avatar.clone(), + display_name: profile.name.clone(), created_at, content, } @@ -200,7 +199,7 @@ impl Chat { this.room.read_with(cx, |this, _| this.member(&item.0)) { this.push_system_message( - format!("{} {}", ALERT, member.name()), + format!("{} {}", member.name, ALERT), cx, ); } @@ -294,7 +293,7 @@ impl Chat { room.members .iter() - .find(|m| m.public_key() == ev.pubkey) + .find(|m| m.public_key == ev.pubkey) .map(|member| { Message::new(ParsedMessage::new(member, &ev.content, ev.created_at)) }) @@ -561,8 +560,11 @@ impl Panel for Chat { fn title(&self, cx: &App) -> AnyElement { self.room .read_with(cx, |this, _| { - let facepill: Vec = - this.members.iter().map(|member| member.avatar()).collect(); + let facepill: Vec = this + .members + .iter() + .map(|member| member.avatar.clone()) + .collect(); div() .flex() diff --git a/crates/app/src/views/contacts.rs b/crates/app/src/views/contacts.rs index c40f7ec..72f859e 100644 --- a/crates/app/src/views/contacts.rs +++ b/crates/app/src/views/contacts.rs @@ -1,11 +1,11 @@ use common::profile::NostrProfile; +use global::get_client; use gpui::{ div, img, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Window, }; use nostr_sdk::prelude::*; -use state::get_client; use ui::{ button::Button, dock_area::panel::{Panel, PanelEvent}, @@ -141,9 +141,9 @@ impl Render for Contacts { .child( div() .flex_shrink_0() - .child(img(item.avatar()).size_6()), + .child(img(item.avatar).size_6()), ) - .child(item.name()), + .child(item.name), ) .hover(|this| { this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)) diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index db101aa..a404a93 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -1,10 +1,11 @@ mod chat; mod contacts; mod profile; -mod relays; mod settings; mod sidebar; mod welcome; pub mod app; pub mod onboarding; +pub mod relays; +pub mod startup; diff --git a/crates/app/src/views/onboarding.rs b/crates/app/src/views/onboarding.rs index c66e7bf..7b71007 100644 --- a/crates/app/src/views/onboarding.rs +++ b/crates/app/src/views/onboarding.rs @@ -1,185 +1,155 @@ -use account::registry::Account; use common::qr::create_qr; use gpui::{ - div, img, prelude::FluentBuilder, relative, svg, App, AppContext, ClipboardItem, Context, Div, - Entity, IntoElement, ParentElement, Render, Styled, Subscription, Window, + div, img, prelude::FluentBuilder, relative, svg, App, AppContext, Context, Entity, IntoElement, + ParentElement, Render, SharedString, Styled, Subscription, Window, }; use nostr_connect::prelude::*; -use std::{path::PathBuf, sync::Arc, time::Duration}; +use smallvec::{smallvec, SmallVec}; +use std::{path::PathBuf, time::Duration}; use ui::{ button::{Button, ButtonCustomVariant, ButtonVariants}, input::{InputEvent, TextInput}, - notification::NotificationType, theme::{scale::ColorScaleStep, ActiveTheme}, - ContextModal, Root, Size, StyledExt, + Disableable, Size, StyledExt, }; -use super::app; +use crate::device::Device; const LOGO_URL: &str = "brand/coop.svg"; const TITLE: &str = "Welcome to Coop!"; const SUBTITLE: &str = "A Nostr client for secure communication."; -const ALPHA_MESSAGE: &str = - "Coop is in the alpha stage of development; It may contain bugs, unfinished features, or unexpected behavior."; - const JOIN_URL: &str = "https://start.njump.me/"; pub fn init(window: &mut Window, cx: &mut App) -> Entity { Onboarding::new(window, cx) } +enum PageKind { + Bunker, + Connect, + Selection, +} + pub struct Onboarding { - app_keys: Keys, - connect_uri: NostrConnectURI, - qr_path: Option, - nsec_input: Entity, - use_connect: bool, - use_privkey: bool, + bunker_input: Entity, + connect_url: Entity>, + error_message: Entity>, + open: PageKind, is_loading: bool, #[allow(dead_code)] - subscriptions: Vec, + subscriptions: SmallVec<[Subscription; 1]>, } impl Onboarding { pub fn new(window: &mut Window, cx: &mut App) -> Entity { - let app_keys = Keys::generate(); + let connect_url = cx.new(|_| None); + let error_message = cx.new(|_| None); + let bunker_input = cx.new(|cx| { + TextInput::new(window, cx) + .text_size(Size::XSmall) + .placeholder("bunker://?relay=wss://relay.example.com") + }); - let connect_uri = NostrConnectURI::client( + cx.new(|cx| { + let mut subscriptions = smallvec![]; + + subscriptions.push(cx.subscribe_in( + &bunker_input, + window, + move |this: &mut Self, _, input_event, window, cx| { + if let InputEvent::PressEnter = input_event { + this.connect(window, cx); + } + }, + )); + + Self { + bunker_input, + connect_url, + error_message, + subscriptions, + open: PageKind::Selection, + is_loading: false, + } + }) + } + + fn login(&self, signer: NostrConnect, _window: &mut Window, cx: &mut Context) { + let Some(device) = Device::global(cx) else { + return; + }; + + let entity = cx.weak_entity(); + + device.update(cx, |this, cx| { + let login = this.login(signer, cx); + + cx.spawn(|_, cx| async move { + if let Err(e) = login.await { + cx.update(|cx| { + entity + .update(cx, |this, cx| { + this.set_error(e.to_string(), cx); + this.set_loading(false, cx); + }) + .ok(); + }) + .ok(); + } + }) + .detach(); + }); + } + + fn connect(&mut self, window: &mut Window, cx: &mut Context) { + let text = self.bunker_input.read(cx).text().to_string(); + let keys = Keys::generate(); + + self.set_loading(true, cx); + + let Ok(uri) = NostrConnectURI::parse(text) else { + self.set_loading(false, cx); + self.set_error("Bunker URL is invalid".to_owned(), cx); + return; + }; + + let Ok(signer) = NostrConnect::new(uri, keys, Duration::from_secs(300), None) else { + self.set_loading(false, cx); + self.set_error("Failed to establish connection".to_owned(), cx); + return; + }; + + self.login(signer, window, cx); + } + + fn wait_for_connection(&mut self, window: &mut Window, cx: &mut Context) { + let app_keys = Keys::generate(); + let url = NostrConnectURI::client( app_keys.public_key(), vec![RelayUrl::parse("wss://relay.nsec.app").unwrap()], "Coop", ); - let nsec_input = cx.new(|cx| { - TextInput::new(window, cx) - .text_size(Size::XSmall) - .placeholder("nsec...") + // Create QR code and save it to a app directory + let qr_path = create_qr(url.to_string().as_str()).ok(); + + // Update QR code path + self.connect_url.update(cx, |this, cx| { + *this = qr_path; + cx.notify(); }); - // Save Connect URI as PNG file for display as QR Code - let qr_path = create_qr(connect_uri.to_string().as_str()).ok(); - - cx.new(|cx| { - // Handle Enter event for nsec input - let subscriptions = vec![cx.subscribe_in( - &nsec_input, - window, - move |this: &mut Self, _, input_event, window, cx| { - if let InputEvent::PressEnter = input_event { - this.login_with_private_key(window, cx); - } - }, - )]; - - Self { - app_keys, - connect_uri, - qr_path, - nsec_input, - use_connect: false, - use_privkey: false, - is_loading: false, - subscriptions, - } - }) - } - - fn login_with_nostr_connect(&mut self, window: &mut Window, cx: &mut Context) { - let uri = self.connect_uri.clone(); - let app_keys = self.app_keys.clone(); - let window_handle = window.window_handle(); - - // Show QR Code for login with Nostr Connect - self.use_connect(window, cx); + // Open Connect page + self.open(PageKind::Connect, window, cx); // Wait for connection - let (tx, rx) = oneshot::channel::(); - - cx.background_spawn(async move { - if let Ok(signer) = NostrConnect::new(uri, app_keys, Duration::from_secs(300), None) { - tx.send(signer).ok(); - } - }) - .detach(); - - cx.spawn(|this, cx| async move { - if let Ok(signer) = rx.await { - cx.spawn(|mut cx| async move { - let signer = Arc::new(signer); - - if Account::login(signer, &cx).await.is_ok() { - _ = cx.update_window(window_handle, |_, window, cx| { - window.replace_root(cx, |window, cx| { - Root::new(app::init(window, cx).into(), window, cx) - }); - }) - } - }) - .detach(); - } else { - _ = cx.update(|cx| { - _ = this.update(cx, |this, cx| { - this.set_loading(false, cx); - }); - }); - } - }) - .detach(); - } - - fn login_with_private_key(&mut self, window: &mut Window, cx: &mut Context) { - let value = self.nsec_input.read(cx).text().to_string(); - let window_handle = window.window_handle(); - - if !value.starts_with("nsec") || value.is_empty() { - window.push_notification((NotificationType::Warning, "Private Key is required"), cx); - return; - } - - let keys = if let Ok(keys) = Keys::parse(&value) { - keys + if let Ok(signer) = NostrConnect::new(url, app_keys, Duration::from_secs(300), None) { + self.login(signer, window, cx); } else { - window.push_notification((NotificationType::Warning, "Private Key isn't valid"), cx); - return; - }; - - // Show loading spinner - self.set_loading(true, cx); - - cx.spawn(|this, mut cx| async move { - let signer = Arc::new(keys); - - if Account::login(signer, &cx).await.is_ok() { - _ = cx.update_window(window_handle, |_, window, cx| { - window.replace_root(cx, |window, cx| { - Root::new(app::init(window, cx).into(), window, cx) - }); - }) - } else { - _ = cx.update(|cx| { - _ = this.update(cx, |this, cx| { - this.set_loading(false, cx); - }); - }); - } - }) - .detach(); - } - - fn use_connect(&mut self, _window: &mut Window, cx: &mut Context) { - self.use_connect = true; - cx.notify(); - } - - fn use_privkey(&mut self, _window: &mut Window, cx: &mut Context) { - self.use_privkey = true; - cx.notify(); - } - - fn reset(&mut self, _window: &mut Window, cx: &mut Context) { - self.use_privkey = false; - self.use_connect = false; - cx.notify(); + self.set_loading(false, cx); + self.set_error("Failed to establish connection".to_owned(), cx); + self.open(PageKind::Selection, window, cx); + } } fn set_loading(&mut self, status: bool, cx: &mut Context) { @@ -187,158 +157,31 @@ impl Onboarding { cx.notify(); } - fn render_selection(&self, window: &mut Window, cx: &mut Context) -> Div { - div() - .w_full() - .flex() - .flex_col() - .items_center() - .justify_center() - .gap_2() - .child( - Button::new("login_connect_btn") - .label("Login with Nostr Connect") - .primary() - .w_full() - .on_click(cx.listener(move |this, _, window, cx| { - this.login_with_nostr_connect(window, cx); - })), - ) - .child( - Button::new("login_privkey_btn") - .label("Login with Private Key") - .custom( - ButtonCustomVariant::new(window, cx) - .color(cx.theme().base.step(cx, ColorScaleStep::THREE)) - .border(cx.theme().base.step(cx, ColorScaleStep::THREE)) - .hover(cx.theme().base.step(cx, ColorScaleStep::FOUR)) - .active(cx.theme().base.step(cx, ColorScaleStep::FIVE)) - .foreground(cx.theme().base.step(cx, ColorScaleStep::TWELVE)), - ) - .w_full() - .on_click(cx.listener(move |this, _, window, cx| { - this.use_privkey(window, cx); - })), - ) - .child( - div() - .my_2() - .h_px() - .rounded_md() - .w_full() - .bg(cx.theme().base.step(cx, ColorScaleStep::THREE)), - ) - .child( - Button::new("join_btn") - .label("Are you new? Join here!") - .ghost() - .w_full() - .on_click(|_, _, cx| { - cx.open_url(JOIN_URL); - }), - ) + fn set_error(&mut self, msg: String, cx: &mut Context) { + self.error_message.update(cx, |this, cx| { + *this = Some(msg.into()); + cx.notify(); + }); + + // Dismiss error message after 3 seconds + cx.spawn(|this, cx| async move { + cx.background_executor().timer(Duration::from_secs(3)).await; + + _ = cx.update(|cx| { + this.update(cx, |this, cx| { + this.error_message.update(cx, |this, cx| { + *this = None; + cx.notify(); + }) + }) + }); + }) + .detach(); } - fn render_connect_login(&self, cx: &mut Context) -> Div { - let connect_string = self.connect_uri.to_string(); - - div() - .w_full() - .flex() - .flex_col() - .items_center() - .justify_center() - .gap_2() - .child( - div() - .flex() - .flex_col() - .text_xs() - .text_center() - .child( - div() - .font_semibold() - .line_height(relative(1.2)) - .child("Scan this QR Code in the Nostr Signer app"), - ) - .child("Recommend: Amber (Android), nsec.app (web),..."), - ) - .when_some(self.qr_path.clone(), |this, path| { - this.child( - div() - .mb_2() - .p_2() - .size_72() - .flex() - .flex_col() - .items_center() - .justify_center() - .gap_2() - .rounded_lg() - .shadow_lg() - .when(cx.theme().appearance.is_dark(), |this| { - this.shadow_none() - .border_1() - .border_color(cx.theme().base.step(cx, ColorScaleStep::SIX)) - }) - .bg(cx.theme().background) - .child(img(path).h_64()), - ) - }) - .child( - Button::new("copy") - .label("Copy Connection String") - .primary() - .w_full() - .on_click(move |_, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(connect_string.clone())) - }), - ) - .child( - Button::new("cancel") - .label("Cancel") - .ghost() - .w_full() - .on_click(cx.listener(move |this, _, window, cx| { - this.reset(window, cx); - })), - ) - } - - fn render_privkey_login(&self, cx: &mut Context) -> Div { - div() - .w_full() - .flex() - .flex_col() - .gap_2() - .child( - div() - .flex() - .flex_col() - .gap_1() - .text_xs() - .child("Private Key:") - .child(self.nsec_input.clone()), - ) - .child( - Button::new("login") - .label("Login") - .primary() - .w_full() - .loading(self.is_loading) - .on_click(cx.listener(move |this, _, window, cx| { - this.login_with_private_key(window, cx); - })), - ) - .child( - Button::new("cancel") - .label("Cancel") - .ghost() - .w_full() - .on_click(cx.listener(move |this, _, window, cx| { - this.reset(window, cx); - })), - ) + fn open(&mut self, kind: PageKind, _window: &mut Window, cx: &mut Context) { + self.open = kind; + cx.notify(); } } @@ -388,28 +231,182 @@ impl Render for Onboarding { ), ), ) - .child( - div() - .w_72() - .map(|_| match (self.use_privkey, self.use_connect) { - (true, _) => self.render_privkey_login(cx), - (_, true) => self.render_connect_login(cx), - _ => self.render_selection(window, cx), - }), - ), - ) - .child( - div() - .absolute() - .bottom_2() - .w_full() - .flex() - .items_center() - .justify_center() - .text_xs() - .text_center() - .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) - .child(ALPHA_MESSAGE), + .child(div().w_72().w_full().flex().flex_col().gap_2().map(|this| { + match self.open { + PageKind::Bunker => this + .child( + div() + .mb_2() + .flex() + .flex_col() + .gap_1() + .text_xs() + .child("Bunker URL:") + .child(self.bunker_input.clone()) + .when_some( + self.error_message.read(cx).as_ref(), + |this, error| { + this.child( + div() + .my_1() + .text_xs() + .text_center() + .text_color(cx.theme().danger) + .child(error.clone()), + ) + }, + ), + ) + .child( + Button::new("login") + .label("Login") + .primary() + .w_full() + .loading(self.is_loading) + .disabled(self.is_loading) + .on_click(cx.listener(move |this, _, window, cx| { + this.connect(window, cx); + })), + ) + .child( + Button::new("use_url") + .label("Get Connection URL") + .custom( + ButtonCustomVariant::new(window, cx) + .color( + cx.theme().base.step(cx, ColorScaleStep::THREE), + ) + .border( + cx.theme().base.step(cx, ColorScaleStep::THREE), + ) + .hover( + cx.theme().base.step(cx, ColorScaleStep::FOUR), + ) + .active( + cx.theme().base.step(cx, ColorScaleStep::FIVE), + ) + .foreground( + cx.theme() + .base + .step(cx, ColorScaleStep::TWELVE), + ), + ) + .w_full() + .on_click(cx.listener(move |this, _, window, cx| { + this.wait_for_connection(window, cx); + })), + ) + .child( + div() + .my_2() + .w_full() + .h_px() + .bg(cx.theme().base.step(cx, ColorScaleStep::THREE)), + ) + .child( + Button::new("cancel") + .label("Cancel") + .ghost() + .w_full() + .on_click(cx.listener(move |this, _, window, cx| { + this.open(PageKind::Selection, window, cx); + })), + ), + PageKind::Connect => this + .when_some(self.connect_url.read(cx).as_ref(), |this, path| { + this.child( + div() + .mb_2() + .p_2() + .size_72() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_2() + .rounded_lg() + .shadow_md() + .when(cx.theme().appearance.is_dark(), |this| { + this.shadow_none().border_1().border_color( + cx.theme().base.step(cx, ColorScaleStep::SIX), + ) + }) + .bg(cx.theme().background) + .child(img(path.as_path()).h_64()), + ) + }) + .child( + div() + .text_xs() + .text_center() + .font_semibold() + .line_height(relative(1.2)) + .child("Scan this QR to connect"), + ) + .child( + Button::new("wait_for_connection") + .label("Waiting for connection") + .custom( + ButtonCustomVariant::new(window, cx) + .color( + cx.theme().base.step(cx, ColorScaleStep::THREE), + ) + .border( + cx.theme().base.step(cx, ColorScaleStep::THREE), + ) + .hover( + cx.theme().base.step(cx, ColorScaleStep::FOUR), + ) + .active( + cx.theme().base.step(cx, ColorScaleStep::FIVE), + ) + .foreground( + cx.theme() + .base + .step(cx, ColorScaleStep::TWELVE), + ), + ) + .w_full() + .loading(true) + .disabled(true), + ) + .child( + div() + .my_2() + .w_full() + .h_px() + .bg(cx.theme().base.step(cx, ColorScaleStep::THREE)), + ) + .child( + Button::new("cancel") + .label("Cancel") + .ghost() + .w_full() + .on_click(cx.listener(move |this, _, window, cx| { + this.open(PageKind::Selection, window, cx); + })), + ), + PageKind::Selection => this + .child( + Button::new("login_connect_btn") + .label("Login with Nostr Connect") + .primary() + .w_full() + .on_click(cx.listener(move |this, _, window, cx| { + this.open(PageKind::Bunker, window, cx); + })), + ) + .child( + Button::new("join_btn") + .label("Are you new? Join here!") + .ghost() + .w_full() + .on_click(|_, _, cx| { + cx.open_url(JOIN_URL); + }), + ), + } + })), ) } } diff --git a/crates/app/src/views/profile.rs b/crates/app/src/views/profile.rs index e8b205b..16e6dc3 100644 --- a/crates/app/src/views/profile.rs +++ b/crates/app/src/views/profile.rs @@ -1,5 +1,6 @@ use async_utility::task::spawn; -use common::{constants::IMAGE_SERVICE, utils::nip96_upload}; +use common::utils::nip96_upload; +use global::{constants::IMAGE_SERVICE, get_client}; use gpui::{ div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity, EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, @@ -7,7 +8,6 @@ use gpui::{ }; use nostr_sdk::prelude::*; use smol::fs; -use state::get_client; use std::{str::FromStr, sync::Arc, time::Duration}; use ui::{ button::{Button, ButtonVariants}, diff --git a/crates/app/src/views/relays.rs b/crates/app/src/views/relays.rs index c4f0701..b4b68e0 100644 --- a/crates/app/src/views/relays.rs +++ b/crates/app/src/views/relays.rs @@ -1,10 +1,12 @@ -use common::constants::NEW_MESSAGE_SUB_ID; +use anyhow::{anyhow, Error}; +use global::{constants::NEW_MESSAGE_SUB_ID, get_client}; use gpui::{ - div, prelude::FluentBuilder, px, uniform_list, AppContext, Context, Entity, FocusHandle, - InteractiveElement, IntoElement, ParentElement, Render, Styled, Task, TextAlign, Window, + div, prelude::FluentBuilder, px, uniform_list, App, AppContext, Context, Entity, FocusHandle, + InteractiveElement, IntoElement, ParentElement, Render, Styled, Subscription, Task, TextAlign, + Window, }; use nostr_sdk::prelude::*; -use state::get_client; +use smallvec::{smallvec, SmallVec}; use ui::{ button::{Button, ButtonVariants}, input::{InputEvent, TextInput}, @@ -12,52 +14,102 @@ use ui::{ ContextModal, IconName, Sizable, }; +use crate::device::Device; + const MESSAGE: &str = "In order to receive messages from others, you need to setup Messaging Relays. You can use the recommend relays or add more."; +const HELP_TEXT: &str = "Please add some relays."; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + Relays::new(window, cx) +} pub struct Relays { - relays: Entity>, + relays: Entity>, input: Entity, focus_handle: FocusHandle, is_loading: bool, + #[allow(dead_code)] + subscriptions: SmallVec<[Subscription; 1]>, } impl Relays { - pub fn new( - relays: Option>, - window: &mut Window, - cx: &mut Context<'_, Self>, - ) -> Self { - let relays = cx.new(|_| { - if let Some(value) = relays { - value.into_iter().map(|v| Url::parse(&v).unwrap()).collect() - } else { - vec![ - Url::parse("wss://auth.nostr1.com").unwrap(), - Url::parse("wss://relay.0xchat.com").unwrap(), - ] - } + pub fn new(window: &mut Window, cx: &mut App) -> Entity { + let client = get_client(); + + let relays = cx.new(|cx| { + let relays = vec![ + RelayUrl::parse("wss://auth.nostr1.com").unwrap(), + RelayUrl::parse("wss://relay.0xchat.com").unwrap(), + ]; + + let task: Task, Error>> = cx.background_spawn(async move { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + + let filter = Filter::new() + .kind(Kind::InboxRelays) + .author(public_key) + .limit(1); + + if let Some(event) = client.database().query(filter).await?.first_owned() { + let relays = event + .tags + .filter_standardized(TagKind::Relay) + .filter_map(|t| match t { + TagStandard::Relay(url) => Some(url.to_owned()), + _ => None, + }) + .collect::>(); + + Ok(relays) + } else { + Err(anyhow!("Messaging Relays not found.")) + } + }); + + cx.spawn(|this, cx| async move { + if let Ok(relays) = task.await { + _ = cx.update(|cx| { + _ = this.update(cx, |this: &mut Vec, cx| { + this.extend(relays); + cx.notify(); + }); + }); + } + }) + .detach(); + + relays }); let input = cx.new(|cx| { TextInput::new(window, cx) .text_size(ui::Size::XSmall) .small() - .placeholder("wss://...") + .placeholder("wss://example.com") }); - cx.subscribe_in(&input, window, move |this, _, input_event, window, cx| { - if let InputEvent::PressEnter = input_event { - this.add(window, cx); + cx.new(|cx| { + let mut subscriptions = smallvec![]; + + subscriptions.push(cx.subscribe_in( + &input, + window, + move |this: &mut Relays, _, input_event, window, cx| { + if let InputEvent::PressEnter = input_event { + this.add(window, cx); + } + }, + )); + + Self { + relays, + input, + subscriptions, + is_loading: false, + focus_handle: cx.focus_handle(), } }) - .detach(); - - Self { - relays, - input, - is_loading: false, - focus_handle: cx.focus_handle(), - } } pub fn update(&mut self, window: &mut Window, cx: &mut Context) { @@ -67,7 +119,7 @@ impl Relays { // Show loading spinner self.set_loading(true, cx); - let task: Task> = cx.background_spawn(async move { + let task: Task> = cx.background_spawn(async move { let client = get_client(); let signer = client.signer().await?; let public_key = signer.get_public_key().await?; @@ -123,13 +175,28 @@ impl Relays { cx.spawn(|this, mut cx| async move { if task.await.is_ok() { - _ = cx.update_window(window_handle, |_, window, cx| { + cx.update_window(window_handle, |_, window, cx| { _ = this.update(cx, |this, cx| { this.set_loading(false, cx); + cx.notify(); }); + if let Some(device) = Device::global(cx) { + let relays = this + .read_with(cx, |this, cx| this.relays.read(cx).clone()) + .unwrap(); + + device.update(cx, |this, cx| { + if let Some(profile) = this.profile() { + let new_profile = profile.clone().relays(Some(relays.into())); + this.set_profile(new_profile, cx); + } + }) + } + window.close_modal(cx); - }); + }) + .ok(); } }) .detach(); @@ -151,7 +218,7 @@ impl Relays { return; } - if let Ok(url) = Url::parse(&value) { + if let Ok(url) = RelayUrl::parse(&value) { self.relays.update(cx, |this, cx| { if !this.contains(&url) { this.push(url); @@ -180,6 +247,7 @@ impl Render for Relays { .flex() .flex_col() .gap_2() + .w_full() .child( div() .px_2() @@ -190,6 +258,7 @@ impl Render for Relays { .child( div() .px_2() + .w_full() .flex() .flex_col() .gap_2() @@ -197,6 +266,7 @@ impl Render for Relays { div() .flex() .items_center() + .w_full() .gap_2() .child(self.input.clone()) .child( @@ -264,6 +334,7 @@ impl Render for Relays { items }, ) + .w_full() .min_h(px(120.)), ) } else { @@ -274,7 +345,7 @@ impl Render for Relays { .justify_center() .text_xs() .text_align(TextAlign::Center) - .child("Please add some relays.") + .child(HELP_TEXT) } }), ) diff --git a/crates/app/src/views/sidebar/compose.rs b/crates/app/src/views/sidebar/compose.rs index 744075a..77e2312 100644 --- a/crates/app/src/views/sidebar/compose.rs +++ b/crates/app/src/views/sidebar/compose.rs @@ -1,5 +1,6 @@ use chats::{registry::ChatRegistry, room::Room}; use common::{profile::NostrProfile, utils::random_name}; +use global::{constants::DEVICE_ANNOUNCEMENT_KIND, get_client}; use gpui::{ div, img, impl_internal_actions, prelude::FluentBuilder, px, relative, uniform_list, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement, @@ -10,7 +11,6 @@ use nostr_sdk::prelude::*; use serde::Deserialize; use smallvec::{smallvec, SmallVec}; use smol::Timer; -use state::get_client; use std::{collections::HashSet, time::Duration}; use ui::{ button::{Button, ButtonRounded}, @@ -214,7 +214,19 @@ impl Compose { // Show loading spinner self.set_loading(true, cx); - let task: Task> = if content.starts_with("npub1") { + let task: Task> = if content.contains("@") { + cx.background_spawn(async move { + let profile = nip05::profile(&content, None).await?; + let public_key = profile.public_key; + + let metadata = client + .fetch_metadata(public_key, Duration::from_secs(2)) + .await + .unwrap_or_default(); + + Ok(NostrProfile::new(public_key, metadata)) + }) + } else { let Ok(public_key) = PublicKey::parse(&content) else { self.set_loading(false, cx); self.set_error(Some("Public Key is not valid".into()), cx); @@ -224,18 +236,8 @@ impl Compose { cx.background_spawn(async move { let metadata = client .fetch_metadata(public_key, Duration::from_secs(2)) - .await?; - - Ok(NostrProfile::new(public_key, metadata)) - }) - } else { - cx.background_spawn(async move { - let profile = nip05::profile(&content, None).await?; - let public_key = profile.public_key; - - let metadata = client - .fetch_metadata(public_key, Duration::from_secs(2)) - .await?; + .await + .unwrap_or_default(); Ok(NostrProfile::new(public_key, metadata)) }) @@ -244,9 +246,27 @@ impl Compose { cx.spawn(|this, mut cx| async move { match task.await { Ok(profile) => { + let public_key = profile.public_key; + + _ = cx + .background_spawn(async move { + let opts = SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE); + + // Create a device announcement filter + let device = Filter::new() + .kind(Kind::Custom(DEVICE_ANNOUNCEMENT_KIND)) + .author(public_key) + .limit(1); + + // Only subscribe to the latest device announcement + client.subscribe(device, Some(opts)).await + }) + .await; + _ = cx.update_window(window_handle, |_, window, cx| { _ = this.update(cx, |this, cx| { - let public_key = profile.public_key(); + let public_key = profile.public_key; this.contacts.update(cx, |this, cx| { this.insert(0, profile); @@ -432,7 +452,7 @@ impl Render for Compose { for ix in range { let item = contacts.get(ix).unwrap().clone(); - let is_select = selected.contains(&item.public_key()); + let is_select = selected.contains(&item.public_key); items.push( div() @@ -451,10 +471,10 @@ impl Render for Compose { .text_xs() .child( div().flex_shrink_0().child( - img(item.avatar()).size_6(), + img(item.avatar).size_6(), ), ) - .child(item.name()), + .child(item.name), ) .when(is_select, |this| { this.child( @@ -475,7 +495,7 @@ impl Render for Compose { .on_click(move |_, window, cx| { window.dispatch_action( Box::new(SelectContact( - item.public_key(), + item.public_key, )), cx, ); diff --git a/crates/app/src/views/sidebar/mod.rs b/crates/app/src/views/sidebar/mod.rs index 52e0e02..f655552 100644 --- a/crates/app/src/views/sidebar/mod.rs +++ b/crates/app/src/views/sidebar/mod.rs @@ -117,8 +117,13 @@ impl Sidebar { this.flex() .items_center() .gap_2() - .child(img(member.avatar()).size_6().rounded_full().flex_shrink_0()) - .child(member.name()) + .child( + img(member.avatar.clone()) + .size_6() + .rounded_full() + .flex_shrink_0(), + ) + .child(member.name.clone()) }) } })) @@ -277,12 +282,11 @@ impl Render for Sidebar { .w_full() .when_some(ChatRegistry::global(cx), |this, state| { let is_loading = state.read(cx).is_loading(); - let rooms = state.read(cx).rooms(); - let len = rooms.len(); + let len = state.read(cx).rooms().len(); if is_loading { this.children(self.render_skeleton(5)) - } else if rooms.is_empty() { + } else if state.read(cx).rooms().is_empty() { this.child( div() .px_1() @@ -323,7 +327,9 @@ impl Render for Sidebar { let mut items = vec![]; for ix in range { - if let Some(room) = rooms.get(ix) { + if let Some(room) = + state.read(cx).rooms().get(ix) + { items.push(this.render_room(ix, room, cx)); } } diff --git a/crates/app/src/views/startup.rs b/crates/app/src/views/startup.rs new file mode 100644 index 0000000..b36360e --- /dev/null +++ b/crates/app/src/views/startup.rs @@ -0,0 +1,32 @@ +use gpui::{ + div, svg, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Window, +}; +use ui::theme::{scale::ColorScaleStep, ActiveTheme}; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + Startup::new(window, cx) +} + +pub struct Startup {} + +impl Startup { + pub fn new(_window: &mut Window, cx: &mut App) -> Entity { + cx.new(|_| Self {}) + } +} + +impl Render for Startup { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child( + svg() + .path("brand/coop.svg") + .size_12() + .text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)), + ) + } +} diff --git a/crates/chats/Cargo.toml b/crates/chats/Cargo.toml index fc84850..4638685 100644 --- a/crates/chats/Cargo.toml +++ b/crates/chats/Cargo.toml @@ -6,7 +6,7 @@ publish = false [dependencies] common = { path = "../common" } -state = { path = "../state" } +global = { path = "../global" } gpui.workspace = true nostr-sdk.workspace = true diff --git a/crates/chats/src/registry.rs b/crates/chats/src/registry.rs index 081b7b7..81f7461 100644 --- a/crates/chats/src/registry.rs +++ b/crates/chats/src/registry.rs @@ -1,11 +1,13 @@ -use crate::room::{IncomingEvent, Room}; +use std::cmp::Reverse; + use anyhow::anyhow; use common::{last_seen::LastSeen, utils::room_hash}; +use global::get_client; use gpui::{App, AppContext, Context, Entity, Global, Task, WeakEntity}; use itertools::Itertools; use nostr_sdk::prelude::*; -use state::get_client; -use std::{cmp::Reverse, rc::Rc, sync::RwLock}; + +use crate::room::{IncomingEvent, Room}; pub fn init(cx: &mut App) { ChatRegistry::register(cx); @@ -16,7 +18,7 @@ struct GlobalChatRegistry(Entity); impl Global for GlobalChatRegistry {} pub struct ChatRegistry { - rooms: Rc>>>, + rooms: Vec>, is_loading: bool, } @@ -28,13 +30,7 @@ impl ChatRegistry { pub fn register(cx: &mut App) -> Entity { Self::global(cx).unwrap_or_else(|| { - let entity = cx.new(|cx| { - let mut this = Self::new(cx); - // Automatically load chat rooms the database when the registry is created - this.load_chat_rooms(cx); - - this - }); + let entity = cx.new(Self::new); // Set global state cx.set_global(GlobalChatRegistry(entity.clone())); @@ -45,18 +41,13 @@ impl ChatRegistry { fn new(_cx: &mut Context) -> Self { Self { - rooms: Rc::new(RwLock::new(vec![])), + rooms: vec![], is_loading: true, } } pub fn current_rooms_ids(&self, cx: &mut Context) -> Vec { - self.rooms - .read() - .unwrap() - .iter() - .map(|room| room.read(cx).id) - .collect() + self.rooms.iter().map(|room| room.read(cx).id).collect() } pub fn load_chat_rooms(&mut self, cx: &mut Context) { @@ -90,10 +81,9 @@ impl ChatRegistry { cx.spawn(|this, cx| async move { if let Ok(events) = task.await { - cx.update(|cx| { - if !events.is_empty() { - this.update(cx, |this, cx| { - let mut rooms = this.rooms.write().unwrap(); + _ = cx.update(|cx| { + _ = this.update(cx, |this, cx| { + if !events.is_empty() { let current_ids = this.current_rooms_ids(cx); let items: Vec> = events .into_iter() @@ -108,29 +98,25 @@ impl ChatRegistry { }) .collect(); - rooms.extend(items); - rooms.sort_by_key(|room| Reverse(room.read(cx).last_seen())); this.is_loading = false; - cx.notify(); - }) - .ok(); - } else { - this.update(cx, |this, cx| { + this.rooms.extend(items); + this.rooms + .sort_by_key(|room| Reverse(room.read(cx).last_seen())); + } else { this.is_loading = false; - cx.notify(); - }) - .ok(); - } - }) - .ok(); + } + + cx.notify(); + }); + }); } }) .detach(); } - pub fn rooms(&self) -> Vec> { - self.rooms.read().unwrap().clone() + pub fn rooms(&self) -> &[Entity] { + &self.rooms } pub fn is_loading(&self) -> bool { @@ -139,8 +125,6 @@ impl ChatRegistry { pub fn get(&self, id: &u64, cx: &App) -> Option> { self.rooms - .read() - .unwrap() .iter() .find(|model| model.read(cx).id == *id) .map(|room| room.downgrade()) @@ -151,44 +135,40 @@ impl ChatRegistry { room: Entity, cx: &mut Context, ) -> Result<(), anyhow::Error> { - let mut rooms = self.rooms.write().unwrap(); - - if !rooms + if !self + .rooms .iter() .any(|current| current.read(cx) == room.read(cx)) { - rooms.insert(0, room); + self.rooms.insert(0, room); cx.notify(); Ok(()) } else { - Err(anyhow!("Room is existed")) + Err(anyhow!("Room already exists")) } } pub fn push_message(&mut self, event: Event, cx: &mut Context) { let id = room_hash(&event); - let mut rooms = self.rooms.write().unwrap(); - if let Some(room) = rooms.iter().find(|room| room.read(cx).id == id) { + if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) { room.update(cx, |this, cx| { - if let Some(last_seen) = Rc::get_mut(&mut this.last_seen) { - *last_seen = LastSeen(event.created_at); - } + this.last_seen = LastSeen(event.created_at); cx.emit(IncomingEvent { event }); cx.notify(); }); - // Re sort rooms by last seen - rooms.sort_by_key(|room| Reverse(room.read(cx).last_seen())); - - cx.notify(); + // Re-sort rooms by last seen + self.rooms + .sort_by_key(|room| Reverse(room.read(cx).last_seen())); } else { let new_room = Room::new(&event, cx); - let mut rooms = self.rooms.write().unwrap(); - rooms.insert(0, new_room); - cx.notify(); + // Push the new room to the front of the list + self.rooms.insert(0, new_room); } + + cx.notify(); } } diff --git a/crates/chats/src/room.rs b/crates/chats/src/room.rs index 86b64e5..9256c9f 100644 --- a/crates/chats/src/room.rs +++ b/crates/chats/src/room.rs @@ -1,10 +1,11 @@ -use anyhow::Error; +use std::collections::HashSet; + +use anyhow::{anyhow, Context, Error}; use common::{last_seen::LastSeen, profile::NostrProfile, utils::room_hash}; +use global::{constants::DEVICE_ANNOUNCEMENT_KIND, get_client, get_device_keys}; use gpui::{App, AppContext, Entity, EventEmitter, SharedString, Task}; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; -use state::get_client; -use std::{collections::HashSet, rc::Rc}; #[derive(Debug, Clone)] pub struct IncomingEvent { @@ -13,7 +14,7 @@ pub struct IncomingEvent { pub struct Room { pub id: u64, - pub last_seen: Rc, + pub last_seen: LastSeen, /// Subject of the room pub name: Option, /// All members of the room @@ -31,7 +32,7 @@ impl PartialEq for Room { impl Room { pub fn new(event: &Event, cx: &mut App) -> Entity { let id = room_hash(event); - let last_seen = Rc::new(LastSeen(event.created_at)); + let last_seen = LastSeen(event.created_at); // Get the subject from the event's tags let name = if let Some(tag) = event.tags.find(TagKind::Subject) { @@ -60,7 +61,7 @@ impl Room { let mut name = profiles .iter() .take(2) - .map(|profile| profile.name().to_string()) + .map(|profile| profile.name.to_string()) .collect::>() .join(", "); @@ -94,7 +95,7 @@ impl Room { pub fn member(&self, public_key: &PublicKey) -> Option { self.members .iter() - .find(|m| &m.public_key() == public_key) + .find(|m| &m.public_key == public_key) .cloned() } @@ -105,7 +106,7 @@ impl Room { /// Collect room's member's public keys pub fn public_keys(&self) -> Vec { - self.members.iter().map(|m| m.public_key()).collect() + self.members.iter().map(|m| m.public_key).collect() } /// Get room's display name @@ -119,8 +120,8 @@ impl Room { } /// Get room's last seen - pub fn last_seen(&self) -> Rc { - self.last_seen.clone() + pub fn last_seen(&self) -> LastSeen { + self.last_seen } /// Get room's last seen as ago format @@ -158,20 +159,26 @@ impl Room { } /// Send message to all room's members + /// + /// NIP-4e: Message will be signed by the device signer pub fn send_message(&self, content: String, cx: &App) -> Task, Error>> { let client = get_client(); let pubkeys = self.public_keys(); cx.background_spawn(async move { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; + let Some(device) = get_device_keys().await else { + return Err(anyhow!("Device not found. Please restart the application.")); + }; - let mut msg = Vec::new(); + let user_signer = client.signer().await?; + let user_pubkey = user_signer.get_public_key().await?; + + let mut report = Vec::with_capacity(pubkeys.len()); let tags: Vec = pubkeys .iter() .filter_map(|pubkey| { - if pubkey != &public_key { + if pubkey != &user_pubkey { Some(Tag::public_key(*pubkey)) } else { None @@ -180,17 +187,52 @@ impl Room { .collect(); for pubkey in pubkeys.iter() { - if let Err(e) = client - .send_private_msg(*pubkey, &content, tags.clone()) - .await - { - log::error!("Failed to send message to {}: {}", pubkey.to_bech32()?, e); - // Convert error into string - msg.push(e.to_string()); + let filter = Filter::new() + .kind(Kind::Custom(DEVICE_ANNOUNCEMENT_KIND)) + .author(*pubkey) + .limit(1); + + // Check if the pubkey has a device announcement, + // then choose the appropriate signer based on device presence + if let Some(event) = client.database().query(filter).await?.first_owned() { + log::info!("Use device signer to send message"); + let signer = &device; + + // Get the device's public key of other user + let n_tag = event.tags.find(TagKind::custom("n")).context("Not found")?; + let hex = n_tag.content().context("Not found")?; + let target_pubkey = PublicKey::parse(hex)?; + + let rumor = EventBuilder::private_msg_rumor(*pubkey, &content) + .tags(tags.clone()) + .build(user_pubkey); + + let event = EventBuilder::gift_wrap( + signer, + &target_pubkey, + rumor, + vec![Tag::public_key(*pubkey)], + ) + .await?; + + if let Err(e) = client.send_event(&event).await { + // Convert error into string, then push it to the report + report.push(e.to_string()); + } + } else { + log::info!("Use user signer to send message"); + let signer = &client.signer().await?; + + let event = + EventBuilder::private_msg(signer, *pubkey, &content, tags.clone()).await?; + + if let Err(e) = client.send_event(&event).await { + report.push(e.to_string()); + } } } - Ok(msg) + Ok(report) }) } diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 078be62..191f712 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -5,12 +5,15 @@ edition = "2021" publish = false [dependencies] +global = { path = "../global" } + gpui.workspace = true nostr-sdk.workspace = true anyhow.workspace = true itertools.workspace = true chrono.workspace = true dirs.workspace = true +smallvec.workspace = true random_name_generator = "0.3.6" qrcode-generator = "5.0.0" diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 38b803f..c27bcdf 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,4 +1,3 @@ -pub mod constants; pub mod last_seen; pub mod profile; pub mod qr; diff --git a/crates/common/src/profile.rs b/crates/common/src/profile.rs index 2f521ac..55ed0b5 100644 --- a/crates/common/src/profile.rs +++ b/crates/common/src/profile.rs @@ -1,13 +1,14 @@ +use global::constants::IMAGE_SERVICE; use gpui::SharedString; use nostr_sdk::prelude::*; - -use crate::constants::IMAGE_SERVICE; +use smallvec::SmallVec; #[derive(Debug, Clone, PartialEq, Eq)] pub struct NostrProfile { - public_key: PublicKey, - avatar: SharedString, - name: SharedString, + pub public_key: PublicKey, + pub avatar: SharedString, + pub name: SharedString, + pub messaging_relays: Option>, } impl NostrProfile { @@ -19,20 +20,14 @@ impl NostrProfile { public_key, name, avatar, + messaging_relays: None, } } - /// Get contact's public key - pub fn public_key(&self) -> PublicKey { - self.public_key - } - - pub fn avatar(&self) -> SharedString { - self.avatar.clone() - } - - pub fn name(&self) -> SharedString { - self.name.clone() + /// Set contact's relays + pub fn relays(mut self, relays: Option>) -> Self { + self.messaging_relays = relays; + self } fn extract_avatar(metadata: &Metadata) -> SharedString { diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index 3c10350..641e2ee 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -1,4 +1,4 @@ -use crate::constants::NIP96_SERVER; +use global::constants::NIP96_SERVER; use itertools::Itertools; use nostr_sdk::prelude::*; use rnglib::{Language, RNG}; diff --git a/crates/state/Cargo.toml b/crates/global/Cargo.toml similarity index 69% rename from crates/state/Cargo.toml rename to crates/global/Cargo.toml index ac6da2e..052062b 100644 --- a/crates/state/Cargo.toml +++ b/crates/global/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "state" +name = "global" version = "0.0.0" edition = "2021" publish = false @@ -7,3 +7,6 @@ publish = false [dependencies] nostr-sdk.workspace = true dirs.workspace = true +smol.workspace = true + +whoami = "1.5.2" diff --git a/crates/common/src/constants.rs b/crates/global/src/constants.rs similarity index 75% rename from crates/common/src/constants.rs rename to crates/global/src/constants.rs index 64ab475..346e8e6 100644 --- a/crates/common/src/constants.rs +++ b/crates/global/src/constants.rs @@ -1,7 +1,14 @@ -pub const KEYRING_SERVICE: &str = "Coop Safe Storage"; pub const APP_NAME: &str = "Coop"; pub const APP_ID: &str = "su.reya.coop"; +pub const KEYRING: &str = "Coop Safe Storage"; +pub const CLIENT_KEYRING: &str = "Coop Client Keys"; +pub const MASTER_KEYRING: &str = "Coop Master Keys"; + +pub const DEVICE_ANNOUNCEMENT_KIND: u16 = 10044; +pub const DEVICE_REQUEST_KIND: u16 = 4454; +pub const DEVICE_RESPONSE_KIND: u16 = 4455; + /// Bootstrap relays pub const BOOTSTRAP_RELAYS: [&str; 3] = [ "wss://relay.damus.io", diff --git a/crates/global/src/lib.rs b/crates/global/src/lib.rs new file mode 100644 index 0000000..258d048 --- /dev/null +++ b/crates/global/src/lib.rs @@ -0,0 +1,104 @@ +use constants::{ALL_MESSAGES_SUB_ID, APP_ID}; +use dirs::config_dir; +use nostr_sdk::prelude::*; +use smol::lock::Mutex; + +use std::{ + fs, + sync::{Arc, OnceLock}, + time::Duration, +}; + +pub mod constants; + +/// Nostr Client +static CLIENT: OnceLock = OnceLock::new(); +/// Current App Name +static APP_NAME: OnceLock> = OnceLock::new(); +/// NIP-4e: Device Keys, used for encryption +static DEVICE_KEYS: Mutex>> = Mutex::new(None); +/// NIP-4e: Device Name, used for display purposes +static DEVICE_NAME: Mutex>> = Mutex::new(None); + +/// Nostr Client instance +pub fn get_client() -> &'static Client { + CLIENT.get_or_init(|| { + // Setup app data folder + let config_dir = config_dir().expect("Config directory not found"); + let app_dir = config_dir.join(APP_ID); + + // Create app directory if it doesn't exist + _ = fs::create_dir_all(&app_dir); + + // Setup database + let lmdb = NostrLMDB::open(app_dir.join("nostr")).expect("Database is NOT initialized"); + + // Client options + let opts = Options::new() + // NIP-65 + .gossip(true) + // Skip all very slow relays + .max_avg_latency(Duration::from_secs(2)); + + // Setup Nostr Client + ClientBuilder::default().database(lmdb).opts(opts).build() + }) +} + +/// Get app name +pub fn get_app_name() -> &'static str { + APP_NAME.get_or_init(|| { + Arc::from(format!( + "Coop on {} ({})", + whoami::distro(), + whoami::devicename() + )) + }) +} + +/// Get device keys +pub async fn get_device_keys() -> Option> { + let guard = DEVICE_KEYS.lock().await; + guard.clone() +} + +/// Set device keys +pub async fn set_device_keys(signer: T) +where + T: NostrSigner + 'static, +{ + DEVICE_KEYS.lock().await.replace(Arc::new(signer)); + + // Re-subscribe to all messages + smol::spawn(async move { + let client = get_client(); + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + + if let Ok(signer) = client.signer().await { + let public_key = signer.get_public_key().await.unwrap(); + + // Create a filter for getting all gift wrapped events send to current user + let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + + let id = SubscriptionId::new(ALL_MESSAGES_SUB_ID); + _ = client.unsubscribe(&id); + _ = client.subscribe_with_id(id, filter, Some(opts)).await; + } + }) + .await; +} + +/// Set master's device name +pub async fn set_device_name(name: &str) { + let mut guard = DEVICE_NAME.lock().await; + + if guard.is_none() { + guard.replace(Arc::new(name.to_owned())); + } +} + +/// Get master's device name +pub fn get_device_name() -> Arc { + let guard = DEVICE_NAME.lock_blocking(); + guard.clone().unwrap_or(Arc::new("Main Device".into())) +} diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs deleted file mode 100644 index 7d446cb..0000000 --- a/crates/state/src/lib.rs +++ /dev/null @@ -1,29 +0,0 @@ -use dirs::config_dir; -use nostr_sdk::prelude::*; -use std::{fs, sync::OnceLock, time::Duration}; - -static CLIENT: OnceLock = OnceLock::new(); - -pub fn get_client() -> &'static Client { - CLIENT.get_or_init(|| { - // Setup app data folder - let config_dir = config_dir().expect("Config directory not found"); - let app_dir = config_dir.join("Coop/"); - - // Create app directory if it doesn't exist - _ = fs::create_dir_all(&app_dir); - - // Setup database - let lmdb = NostrLMDB::open(app_dir.join("nostr")).expect("Database is NOT initialized"); - - // Client options - let opts = Options::new() - // NIP-65 - .gossip(true) - // Skip all very slow relays - .max_avg_latency(Duration::from_millis(800)); - - // Setup Nostr Client - ClientBuilder::default().database(lmdb).opts(opts).build() - }) -} diff --git a/crates/ui/src/modal.rs b/crates/ui/src/modal.rs index b13c3c6..85df059 100644 --- a/crates/ui/src/modal.rs +++ b/crates/ui/src/modal.rs @@ -47,8 +47,7 @@ impl Modal { .border_1() .border_color(cx.theme().base.step(cx, ColorScaleStep::FIVE)) .rounded_lg() - .shadow_xl() - .min_h_48(); + .shadow_xl(); Self { base, diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 59c91e4..4e6b0d1 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -212,6 +212,11 @@ impl Root { pub fn view(&self) -> &AnyView { &self.view } + + /// Replace the root view of the Root. + pub fn replace_view(&mut self, view: AnyView) { + self.view = view; + } } impl Render for Root {