diff --git a/crates/app_state/src/constants.rs b/crates/app_state/src/constants.rs index e59eb473..1cd88730 100644 --- a/crates/app_state/src/constants.rs +++ b/crates/app_state/src/constants.rs @@ -19,17 +19,6 @@ pub const BOOTSTRAP_RELAYS: [&str; 5] = [ /// Search Relays. pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"]; -/// NIP65 Relays. Used for new account -pub const NIP65_RELAYS: [&str; 4] = [ - "wss://relay.damus.io", - "wss://relay.primal.net", - "wss://relay.nostr.net", - "wss://nos.lol", -]; - -/// Messaging Relays. Used for new account -pub const NIP17_RELAYS: [&str; 2] = ["wss://nip17.com", "wss://auth.nostr1.com"]; - /// Default relay for Nostr Connect pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app"; diff --git a/crates/app_state/src/lib.rs b/crates/app_state/src/lib.rs index 3c24878e..9747d27c 100644 --- a/crates/app_state/src/lib.rs +++ b/crates/app_state/src/lib.rs @@ -13,6 +13,8 @@ pub mod state; static APP_STATE: OnceLock = OnceLock::new(); static NOSTR_CLIENT: OnceLock = OnceLock::new(); +static NIP65_RELAYS: OnceLock)>> = OnceLock::new(); +static NIP17_RELAYS: OnceLock> = OnceLock::new(); /// Initialize the application state. pub fn app_state() -> &'static AppState { @@ -42,3 +44,39 @@ pub fn nostr_client() -> &'static Client { ClientBuilder::default().database(lmdb).opts(opts).build() }) } + +/// Default NIP65 Relays. Used for new account +pub fn default_nip65_relays() -> &'static Vec<(RelayUrl, Option)> { + NIP65_RELAYS.get_or_init(|| { + vec![ + ( + RelayUrl::parse("wss://nostr.mom").unwrap(), + Some(RelayMetadata::Read), + ), + ( + RelayUrl::parse("wss://nostr.bitcoiner.social").unwrap(), + Some(RelayMetadata::Read), + ), + ( + RelayUrl::parse("wss://nostr.oxtr.dev").unwrap(), + Some(RelayMetadata::Write), + ), + ( + RelayUrl::parse("wss://nostr.fmt.wiz.biz").unwrap(), + Some(RelayMetadata::Write), + ), + (RelayUrl::parse("wss://relay.primal.net").unwrap(), None), + (RelayUrl::parse("wss://relay.damus.io").unwrap(), None), + ] + }) +} + +/// Default NIP17 Relays. Used for new account +pub fn default_nip17_relays() -> &'static Vec { + NIP17_RELAYS.get_or_init(|| { + vec![ + RelayUrl::parse("wss://nip17.com").unwrap(), + RelayUrl::parse("wss://auth.nostr1.com").unwrap(), + ] + }) +} diff --git a/crates/app_state/src/state/gossip.rs b/crates/app_state/src/state/gossip.rs index 78a47db6..779e5429 100644 --- a/crates/app_state/src/state/gossip.rs +++ b/crates/app_state/src/state/gossip.rs @@ -15,10 +15,12 @@ pub struct Gossip { } impl Gossip { + /// Parse and insert NIP-65 or NIP-17 relays into the gossip state. pub fn insert(&mut self, event: &Event) { match event.kind { Kind::InboxRelays => { - let urls: Vec = nip17::extract_relay_list(event).cloned().collect(); + let urls: Vec = + nip17::extract_relay_list(event).take(3).cloned().collect(); if !urls.is_empty() { self.nip17.entry(event.pubkey).or_default().extend(urls); @@ -37,6 +39,7 @@ impl Gossip { } } + /// Get all write relays for a given public key pub fn write_relays(&self, public_key: &PublicKey) -> Vec<&RelayUrl> { self.nip65 .get(public_key) @@ -51,6 +54,7 @@ impl Gossip { .unwrap_or_default() } + /// Get all read relays for a given public key pub fn read_relays(&self, public_key: &PublicKey) -> Vec<&RelayUrl> { self.nip65 .get(public_key) @@ -65,6 +69,7 @@ impl Gossip { .unwrap_or_default() } + /// Get all messaging relays for a given public key pub fn messaging_relays(&self, public_key: &PublicKey) -> Vec<&RelayUrl> { self.nip17 .get(public_key) @@ -72,17 +77,30 @@ impl Gossip { .unwrap_or_default() } - pub async fn get_nip65(&mut self, public_key: PublicKey) -> Result<(), Error> { + /// Get and verify NIP-65 relays for a given public key + /// + /// Only fetch from the public relays + pub async fn get_nip65(&self, public_key: PublicKey) -> Result<(), Error> { let client = nostr_client(); let timeout = Duration::from_secs(5); let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let filter = Filter::new() + let latest_filter = Filter::new() .kind(Kind::RelayList) .author(public_key) .limit(1); // Subscribe to events from the bootstrapping relays + client + .subscribe_to(BOOTSTRAP_RELAYS, latest_filter.clone(), Some(opts)) + .await?; + + let filter = Filter::new() + .kind(Kind::RelayList) + .author(public_key) + .since(Timestamp::now()); + + // Continuously subscribe for new events from the bootstrap relays client .subscribe_to(BOOTSTRAP_RELAYS, filter.clone(), Some(opts)) .await?; @@ -91,7 +109,7 @@ impl Gossip { smol::spawn(async move { smol::Timer::after(timeout).await; - if client.database().count(filter).await.unwrap_or(0) < 1 { + if client.database().count(latest_filter).await.unwrap_or(0) < 1 { app_state() .signal .send(SignalKind::GossipRelaysNotFound) @@ -103,16 +121,49 @@ impl Gossip { Ok(()) } - pub async fn get_nip17(&mut self, public_key: PublicKey) -> Result<(), Error> { + /// Set NIP-65 relays for a current user + pub async fn set_nip65( + &mut self, + relays: &[(RelayUrl, Option)], + ) -> Result<(), Error> { + let client = nostr_client(); + let signer = client.signer().await?; + + let tags: Vec = relays + .iter() + .map(|(url, metadata)| Tag::relay_metadata(url.to_owned(), metadata.to_owned())) + .collect(); + + let event = EventBuilder::new(Kind::RelayList, "") + .tags(tags) + .sign(&signer) + .await?; + + // Send event to the public relays + client.send_event_to(BOOTSTRAP_RELAYS, &event).await?; + + // Update gossip data + for relay in relays { + self.nip65 + .entry(event.pubkey) + .or_default() + .insert(relay.to_owned()); + } + + // Get NIP-17 relays + self.get_nip17(event.pubkey).await?; + + Ok(()) + } + + /// Get and verify NIP-17 relays for a given public key + /// + /// Only fetch from public key's write relays + pub async fn get_nip17(&self, public_key: PublicKey) -> Result<(), Error> { let client = nostr_client(); let timeout = Duration::from_secs(5); let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let filter = Filter::new() - .kind(Kind::InboxRelays) - .author(public_key) - .limit(1); - let urls = self.write_relays(&public_key); // Ensure user's have at least one write relay @@ -126,7 +177,22 @@ impl Gossip { client.connect_relay(url).await?; } + let latest_filter = Filter::new() + .kind(Kind::InboxRelays) + .author(public_key) + .limit(1); + // Subscribe to events from the bootstrapping relays + client + .subscribe_to(urls.clone(), latest_filter.clone(), Some(opts)) + .await?; + + let filter = Filter::new() + .kind(Kind::InboxRelays) + .author(public_key) + .since(Timestamp::now()); + + // Continuously subscribe for new events from the bootstrap relays client .subscribe_to(urls, filter.clone(), Some(opts)) .await?; @@ -135,7 +201,7 @@ impl Gossip { smol::spawn(async move { smol::Timer::after(timeout).await; - if client.database().count(filter).await.unwrap_or(0) < 1 { + if client.database().count(latest_filter).await.unwrap_or(0) < 1 { app_state() .signal .send(SignalKind::MessagingRelaysNotFound) @@ -147,7 +213,51 @@ impl Gossip { Ok(()) } - pub async fn subscribe(&mut self, public_key: PublicKey, kind: Kind) -> Result<(), Error> { + /// Set NIP-17 relays for a current user + pub async fn set_nip17(&mut self, relays: &[RelayUrl]) -> Result<(), Error> { + let client = nostr_client(); + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + + let urls = self.write_relays(&public_key); + + // Ensure user's have at least one relay + if urls.is_empty() { + return Err(anyhow!("Relays are empty")); + } + + // Ensure connection to relays + for url in urls.iter().cloned() { + client.add_relay(url).await?; + client.connect_relay(url).await?; + } + + let event = EventBuilder::new(Kind::InboxRelays, "") + .tags(relays.iter().map(|relay| Tag::relay(relay.to_owned()))) + .sign(&signer) + .await?; + + // Send event to the public relays + client.send_event_to(urls, &event).await?; + + // Update gossip data + for relay in relays { + self.nip17 + .entry(event.pubkey) + .or_default() + .insert(relay.to_owned()); + } + + // Run inbox monitor + self.monitor_inbox(event.pubkey).await?; + + Ok(()) + } + + /// Subscribe for events that match the given kind for a given author + /// + /// Only fetch from author's write relays + pub async fn subscribe(&self, public_key: PublicKey, kind: Kind) -> Result<(), Error> { let client = nostr_client(); let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); @@ -171,7 +281,10 @@ impl Gossip { Ok(()) } - pub async fn bulk_subscribe(&mut self, public_keys: HashSet) -> Result<(), Error> { + /// Bulk subscribe to metadata events for a list of public keys + /// + /// Only fetch from the public relays + pub async fn bulk_subscribe(&self, public_keys: HashSet) -> Result<(), Error> { if public_keys.is_empty() { return Err(anyhow!("You need at least one public key")); } @@ -192,7 +305,7 @@ impl Gossip { } /// Monitor all gift wrap events in the messaging relays for a given public key - pub async fn monitor_inbox(&mut self, public_key: PublicKey) -> Result<(), Error> { + pub async fn monitor_inbox(&self, public_key: PublicKey) -> Result<(), Error> { let client = nostr_client(); let id = SubscriptionId::new("inbox"); let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); @@ -214,4 +327,27 @@ impl Gossip { Ok(()) } + + /// Send an event to author's write relays + pub async fn send_event_to_write_relays(&self, event: &Event) -> Result<(), Error> { + let client = nostr_client(); + let public_key = event.pubkey; + let urls = self.write_relays(&public_key); + + // Ensure user's have at least one relay + if urls.is_empty() { + return Err(anyhow!("Relays are empty")); + } + + // Ensure connection to relays + for url in urls.iter().cloned() { + client.add_relay(url).await?; + client.connect_relay(url).await?; + } + + // Send event to relays + client.send_event(event).await?; + + Ok(()) + } } diff --git a/crates/app_state/src/state/mod.rs b/crates/app_state/src/state/mod.rs index e3c5f1d6..73c91bad 100644 --- a/crates/app_state/src/state/mod.rs +++ b/crates/app_state/src/state/mod.rs @@ -15,7 +15,7 @@ use crate::nostr_client; use crate::paths::support_dir; use crate::state::gossip::Gossip; -pub mod gossip; +mod gossip; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct AuthRequest { @@ -267,14 +267,18 @@ impl AppState { match event.kind { Kind::RelayList => { - let mut gossip = self.gossip.write().await; - let is_self_authored = Self::is_self_authored(&event).await; - // Update NIP-65 relays for event's public key - gossip.insert(&event); + { + let mut gossip = self.gossip.write().await; + gossip.insert(&event); + } + + let is_self_authored = Self::is_self_authored(&event).await; // Get events if relay list belongs to current user if is_self_authored { + let gossip = self.gossip.read().await; + // Fetch user's metadata event gossip.subscribe(event.pubkey, Kind::Metadata).await.ok(); @@ -286,16 +290,19 @@ impl AppState { } } Kind::InboxRelays => { - let mut gossip = self.gossip.write().await; - let is_self_authored = Self::is_self_authored(&event).await; - // Update NIP-17 relays for event's public key - gossip.insert(&event); + { + let mut gossip = self.gossip.write().await; + gossip.insert(&event); + } + + let is_self_authored = Self::is_self_authored(&event).await; // Subscribe to gift wrap events if messaging relays belong to the current user if is_self_authored { - if let Err(e) = gossip.monitor_inbox(event.pubkey).await { - log::error!("Error: {e}"); + let gossip = self.gossip.read().await; + + if gossip.monitor_inbox(event.pubkey).await.is_err() { self.signal.send(SignalKind::MessagingRelaysNotFound).await; } } @@ -304,11 +311,15 @@ impl AppState { let is_self_authored = Self::is_self_authored(&event).await; if is_self_authored { - let mut gossip = self.gossip.write().await; let public_keys: HashSet = event.tags.public_keys().copied().collect(); - gossip.bulk_subscribe(public_keys).await.ok(); + self.gossip + .read() + .await + .bulk_subscribe(public_keys) + .await + .ok(); } } Kind::Metadata => { @@ -395,16 +406,16 @@ impl AppState { // Process the batch if it's full if batch.len() >= METADATA_BATCH_LIMIT { - let mut gossip = self.gossip.write().await; + let gossip = self.gossip.read().await; gossip.bulk_subscribe(std::mem::take(&mut batch)).await.ok(); } } BatchEvent::Timeout => { - let mut gossip = self.gossip.write().await; + let gossip = self.gossip.read().await; gossip.bulk_subscribe(std::mem::take(&mut batch)).await.ok(); } BatchEvent::Closed => { - let mut gossip = self.gossip.write().await; + let gossip = self.gossip.read().await; gossip.bulk_subscribe(std::mem::take(&mut batch)).await.ok(); // Exit the current loop diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs index 5875be93..31e84ce6 100644 --- a/crates/coop/src/chatspace.rs +++ b/crates/coop/src/chatspace.rs @@ -7,15 +7,15 @@ use std::time::Duration; use anyhow::{anyhow, Error}; use app_state::constants::{ACCOUNT_IDENTIFIER, BOOTSTRAP_RELAYS, DEFAULT_SIDEBAR_WIDTH}; use app_state::state::{AuthRequest, SignalKind, UnwrappingStatus}; -use app_state::{app_state, nostr_client}; +use app_state::{app_state, default_nip17_relays, default_nip65_relays, nostr_client}; use auto_update::AutoUpdater; use client_keys::ClientKeys; use common::display::RenderedProfile; use common::event::EventUtils; use gpui::prelude::FluentBuilder; use gpui::{ - deferred, div, px, rems, App, AppContext, AsyncWindowContext, Axis, ClipboardItem, Context, - Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString, + deferred, div, px, relative, rems, App, AppContext, AsyncWindowContext, Axis, ClipboardItem, + Context, Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, }; use i18n::{shared_t, t}; @@ -41,7 +41,7 @@ use ui::{h_flex, v_flex, ContextModal, Disableable, IconName, Root, Sizable, Sty use crate::actions::{DarkMode, Logout, ReloadMetadata, Settings}; use crate::views::compose::compose_button; -use crate::views::setup_relay::setup_nip17_relay; +use crate::views::setup_relay::SetupRelay; use crate::views::{ account, chat, login, new_account, onboarding, preferences, sidebar, user_profile, welcome, }; @@ -61,22 +61,25 @@ pub fn new_account(window: &mut Window, cx: &mut App) { } pub struct ChatSpace { - // App's Title Bar + /// App's Title Bar title_bar: Entity, - // App's Dock Area + /// App's Dock Area dock: Entity, - // All authentication requests + /// All authentication requests auth_requests: Entity>, - // Local state to determine if the user has set up NIP-17 relays - nip17_relays: bool, + /// Local state to determine if the user has set up NIP-17 relays + nip17_ready: bool, - // All subscriptions for observing the app state + /// Local state to determine if the user has set up NIP-65 relays + nip65_ready: bool, + + /// All subscriptions for observing the app state _subscriptions: SmallVec<[Subscription; 4]>, - // All long running tasks + /// All long running tasks _tasks: SmallVec<[Task<()>; 5]>, } @@ -203,7 +206,8 @@ impl ChatSpace { dock, title_bar, auth_requests, - nip17_relays: true, + nip17_ready: true, + nip65_ready: true, _subscriptions: subscriptions, _tasks: tasks, } @@ -361,13 +365,14 @@ impl ChatSpace { } SignalKind::GossipRelaysNotFound => { view.update(cx, |this, cx| { - this.set_required_relays(cx); + this.set_required_gossip_relays(cx); + this.render_setup_gossip_relays_modal(window, cx); }) .ok(); } SignalKind::MessagingRelaysNotFound => { view.update(cx, |this, cx| { - this.set_required_relays(cx); + this.set_required_dm_relays(cx); }) .ok(); } @@ -639,8 +644,13 @@ impl ChatSpace { }); } - fn set_required_relays(&mut self, cx: &mut Context) { - self.nip17_relays = false; + fn set_required_dm_relays(&mut self, cx: &mut Context) { + self.nip17_ready = false; + cx.notify(); + } + + fn set_required_gossip_relays(&mut self, cx: &mut Context) { + self.nip65_ready = false; cx.notify(); } @@ -789,6 +799,212 @@ impl ChatSpace { window.push_notification(t!("common.copied"), cx); } + fn render_setup_gossip_relays_modal(&mut self, window: &mut Window, cx: &mut App) { + let relays = default_nip65_relays(); + + window.open_modal(cx, move |this, _window, cx| { + this.overlay_closable(false) + .show_close(false) + .keyboard(false) + .confirm() + .button_props( + ModalButtonProps::default() + .cancel_text(t!("common.configure")) + .ok_text(t!("common.use_default")), + ) + .title(shared_t!("mailbox.modal")) + .child( + v_flex() + .gap_2() + .text_sm() + .child(shared_t!("mailbox.description")) + .child( + v_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().text_muted) + .child(shared_t!("mailbox.write_label")) + .child(shared_t!("mailbox.read_label")), + ) + .child( + div() + .font_semibold() + .text_xs() + .child(shared_t!("common.default")), + ) + .child(v_flex().gap_1().children({ + let mut items = Vec::with_capacity(relays.len()); + + for (url, metadata) in relays { + items.push( + div() + .h_7() + .px_1p5() + .h_flex() + .justify_between() + .rounded(cx.theme().radius) + .bg(cx.theme().elevated_surface_background) + .text_sm() + .child( + div() + .line_height(relative(1.2)) + .child(SharedString::from(url.to_string())), + ) + .when_some(metadata.as_ref(), |this, metadata| { + this.child( + div() + .text_xs() + .font_semibold() + .line_height(relative(1.2)) + .child(SharedString::from( + metadata.to_string(), + )), + ) + }), + ); + } + + items + })), + ) + .on_cancel(|_, _window, _cx| { + // TODO: add configure relays + // true to close the modal + true + }) + .on_ok(|_, window, cx| { + window + .spawn(cx, async move |cx| { + let app_state = app_state(); + let relays = default_nip65_relays(); + + let mut gossip = app_state.gossip.write().await; + let result = gossip.set_nip65(relays).await; + + cx.update(|window, cx| { + match result { + Ok(_) => { + window.close_modal(cx); + } + Err(e) => { + window.push_notification(e.to_string(), cx); + } + }; + }) + .ok(); + }) + .detach(); + + // false to keep modal open + false + }) + }) + } + + fn render_setup_dm_relays_modal(window: &mut Window, cx: &mut App) { + let relays = default_nip17_relays(); + + window.open_modal(cx, move |this, _window, cx| { + this.overlay_closable(false) + .show_close(false) + .keyboard(false) + .confirm() + .button_props( + ModalButtonProps::default() + .cancel_text(t!("common.configure")) + .ok_text(t!("common.use_default")), + ) + .title(shared_t!("messaging.modal")) + .child( + v_flex() + .gap_2() + .text_sm() + .child(shared_t!("messaging.description")) + .child( + div() + .font_semibold() + .text_xs() + .child(shared_t!("common.default")), + ) + .child(v_flex().gap_1().children({ + let mut items = Vec::with_capacity(relays.len()); + + for url in relays { + items.push( + div() + .h_7() + .px_1p5() + .h_flex() + .justify_between() + .rounded(cx.theme().radius) + .bg(cx.theme().elevated_surface_background) + .text_sm() + .child( + div() + .line_height(relative(1.2)) + .child(SharedString::from(url.to_string())), + ), + ); + } + + items + })), + ) + .on_cancel(|_, window, cx| { + let view = cx.new(|cx| SetupRelay::new(window, cx)); + let weak_view = view.downgrade(); + + window.open_modal(cx, move |modal, _window, _cx| { + let weak_view = weak_view.clone(); + + modal + .confirm() + .title(shared_t!("relays.modal")) + .child(view.clone()) + .button_props(ModalButtonProps::default().ok_text(t!("common.update"))) + .on_ok(move |_, window, cx| { + weak_view + .update(cx, |this, cx| { + this.set_relays(window, cx); + }) + .ok(); + // true to close the modal + false + }) + }); + + // true to close the modal + true + }) + .on_ok(|_, window, cx| { + window + .spawn(cx, async move |cx| { + let app_state = app_state(); + let relays = default_nip17_relays(); + + let mut gossip = app_state.gossip.write().await; + let result = gossip.set_nip17(relays).await; + + cx.update(|window, cx| { + match result { + Ok(_) => { + window.close_modal(cx); + } + Err(e) => { + window.push_notification(e.to_string(), cx); + } + }; + }) + .ok(); + }) + .detach(); + + // false to keep modal open + false + }) + }) + } + fn render_proxy_modal(&mut self, window: &mut Window, cx: &mut App) { window.open_modal(cx, |this, _window, _cx| { this.overlay_closable(false) @@ -954,8 +1170,18 @@ impl ChatSpace { })), ) }) - .when(!self.nip17_relays, |this| { - this.child(setup_nip17_relay(t!("relays.button"))) + .when(!self.nip17_ready, |this| { + this.child( + Button::new("setup-relays-button") + .icon(IconName::Info) + .label(t!("messaging.button")) + .warning() + .xsmall() + .rounded() + .on_click(move |_ev, window, cx| { + Self::render_setup_dm_relays_modal(window, cx); + }), + ) }) .child( Button::new("user") diff --git a/crates/coop/src/views/edit_profile.rs b/crates/coop/src/views/edit_profile.rs index ed0dc77f..377a3de9 100644 --- a/crates/coop/src/views/edit_profile.rs +++ b/crates/coop/src/views/edit_profile.rs @@ -1,7 +1,8 @@ use std::str::FromStr; use std::time::Duration; -use app_state::nostr_client; +use anyhow::Error; +use app_state::{app_state, nostr_client}; use common::nip96::nip96_upload; use gpui::prelude::FluentBuilder; use gpui::{ @@ -164,7 +165,7 @@ impl EditProfile { .detach(); } - pub fn set_metadata(&mut self, cx: &mut Context) -> Task, Error>> { + pub fn set_metadata(&mut self, cx: &mut Context) -> Task> { let avatar = self.avatar_input.read(cx).value().to_string(); let name = self.name_input.read(cx).value().to_string(); let bio = self.bio_input.read(cx).value().to_string(); @@ -187,18 +188,23 @@ impl EditProfile { } cx.background_spawn(async move { - let client = nostr_client(); - let output = client.set_metadata(&new_metadata).await?; - let event = client - .database() - .event_by_id(&output.val) - .await? - .map(|event| { - let metadata = Metadata::from_json(&event.content).unwrap_or_default(); - Profile::new(event.pubkey, metadata) - }); + let app_state = app_state(); + let gossip = app_state.gossip.read().await; - Ok(event) + let client = nostr_client(); + let signer = client.signer().await?; + + // Sign the new metadata event + let event = EventBuilder::metadata(&new_metadata).sign(&signer).await?; + + // Send event to user's write relayss + gossip.send_event_to_write_relays(&event).await?; + + // Return the updated profile + let metadata = Metadata::from_json(&event.content).unwrap_or_default(); + let profile = Profile::new(event.pubkey, metadata); + + Ok(profile) }) } diff --git a/crates/coop/src/views/new_account.rs b/crates/coop/src/views/new_account.rs index b9635574..7acf0067 100644 --- a/crates/coop/src/views/new_account.rs +++ b/crates/coop/src/views/new_account.rs @@ -1,11 +1,11 @@ -use anyhow::anyhow; -use app_state::constants::{ACCOUNT_IDENTIFIER, NIP17_RELAYS, NIP65_RELAYS}; -use app_state::nostr_client; +use anyhow::{anyhow, Error}; +use app_state::constants::{ACCOUNT_IDENTIFIER, BOOTSTRAP_RELAYS}; +use app_state::{app_state, default_nip17_relays, default_nip65_relays, nostr_client}; use common::nip96::nip96_upload; use gpui::{ div, relative, rems, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity, EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, - Render, SharedString, Styled, WeakEntity, Window, + Render, SharedString, Styled, Task, WeakEntity, Window, }; use gpui_tokio::Tokio; use i18n::{shared_t, t}; @@ -123,48 +123,51 @@ impl NewAccount { self.write_keys_to_disk(&keys, password, cx); // Set the client's signer with the current keys - cx.background_spawn(async move { + let task: Task> = cx.background_spawn(async move { let client = nostr_client(); + let app_state = app_state(); + let gossip = app_state.gossip.read().await; // Set the client's signer with the current keys client.set_signer(keys).await; - // Set metadata - if let Err(e) = client.set_metadata(&metadata).await { - log::error!("Failed to set metadata: {e}"); - } + // Verify the signer + let signer = client.signer().await?; + + // Construct a NIP-65 event + let event = EventBuilder::new(Kind::RelayList, "") + .tags(default_nip65_relays().iter().map(|(url, metadata)| { + Tag::relay_metadata(url.to_owned(), metadata.to_owned()) + })) + .sign(&signer) + .await?; // Set NIP-65 relays - let builder = EventBuilder::new(Kind::RelayList, "").tags( - NIP65_RELAYS.into_iter().filter_map(|url| { - if let Ok(url) = RelayUrl::parse(url) { - Some(Tag::relay_metadata(url, None)) - } else { - None - } - }), - ); + client.send_event_to(BOOTSTRAP_RELAYS, &event).await?; - if let Err(e) = client.send_event_builder(builder).await { - log::error!("Failed to send NIP-65 relay list event: {e}"); - } + // Construct a NIP-17 event + let event = EventBuilder::new(Kind::InboxRelays, "") + .tags( + default_nip17_relays() + .iter() + .map(|url| Tag::relay(url.to_owned())), + ) + .sign(&signer) + .await?; // Set NIP-17 relays - let builder = EventBuilder::new(Kind::InboxRelays, "").tags( - NIP17_RELAYS.into_iter().filter_map(|url| { - if let Ok(url) = RelayUrl::parse(url) { - Some(Tag::relay(url)) - } else { - None - } - }), - ); + gossip.send_event_to_write_relays(&event).await?; - if let Err(e) = client.send_event_builder(builder).await { - log::error!("Failed to send messaging relay list event: {e}"); - }; - }) - .detach(); + // Construct a metadata event + let event = EventBuilder::metadata(&metadata).sign(&signer).await?; + + // Set metadata + gossip.send_event_to_write_relays(&event).await?; + + Ok(()) + }); + + task.detach(); } fn write_keys_to_disk(&self, keys: &Keys, password: String, cx: &mut Context) { diff --git a/crates/coop/src/views/preferences.rs b/crates/coop/src/views/preferences.rs index 5993d717..4cd72ec2 100644 --- a/crates/coop/src/views/preferences.rs +++ b/crates/coop/src/views/preferences.rs @@ -6,7 +6,6 @@ use gpui::{ ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window, }; use i18n::{shared_t, t}; -use nostr_sdk::prelude::*; use registry::Registry; use settings::AppSettings; use theme::ActiveTheme; @@ -54,28 +53,25 @@ impl Preferences { .on_ok(move |_, window, cx| { weak_view .update(cx, |this, cx| { - let set_metadata = this.set_metadata(cx); let registry = Registry::global(cx); + let set_metadata = this.set_metadata(cx); - cx.spawn_in(window, async move |_, cx| { - match set_metadata.await { - Ok(profile) => { - if let Some(profile) = profile { - cx.update(|_, cx| { - registry.update(cx, |this, cx| { - this.insert_or_update_person(profile, cx); - }); - }) - .ok(); + cx.spawn_in(window, async move |this, cx| { + let result = set_metadata.await; + + this.update_in(cx, |_, window, cx| { + match result { + Ok(profile) => { + registry.update(cx, |this, cx| { + this.insert_or_update_person(profile, cx); + }); } - } - Err(e) => { - cx.update(|window, cx| { + Err(e) => { window.push_notification(e.to_string(), cx); - }) - .ok(); - } - }; + } + }; + }) + .ok(); }) .detach(); }) @@ -87,7 +83,7 @@ impl Preferences { } fn open_relays(&self, window: &mut Window, cx: &mut Context) { - let view = setup_relay::init(Kind::InboxRelays, window, cx); + let view = setup_relay::init(window, cx); let weak_view = view.downgrade(); window.open_modal(cx, move |this, _window, _cx| { diff --git a/crates/coop/src/views/screening.rs b/crates/coop/src/views/screening.rs index 905d63c6..1da9d450 100644 --- a/crates/coop/src/views/screening.rs +++ b/crates/coop/src/views/screening.rs @@ -158,11 +158,13 @@ impl Screening { let task: Task> = cx.background_spawn(async move { let client = nostr_client(); - let builder = EventBuilder::report( - vec![Tag::public_key_report(public_key, Report::Impersonation)], - "scam/impersonation", - ); - let _ = client.send_event_builder(builder).await?; + let signer = client.signer().await?; + + let tag = Tag::public_key_report(public_key, Report::Impersonation); + let event = EventBuilder::report(vec![tag], "").sign(&signer).await?; + + // Send the report to the public relays + client.send_event_to(BOOTSTRAP_RELAYS, &event).await?; Ok(()) }); diff --git a/crates/coop/src/views/setup_relay.rs b/crates/coop/src/views/setup_relay.rs index 4d87ff36..a5e651b3 100644 --- a/crates/coop/src/views/setup_relay.rs +++ b/crates/coop/src/views/setup_relay.rs @@ -1,13 +1,13 @@ +use std::collections::HashSet; use std::time::Duration; use anyhow::{anyhow, Error}; -use app_state::constants::NIP17_RELAYS; use app_state::{app_state, nostr_client}; use gpui::prelude::FluentBuilder; use gpui::{ - div, px, uniform_list, App, AppContext, Context, Entity, InteractiveElement, IntoElement, - ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, - TextAlign, UniformList, Window, + div, px, uniform_list, App, AppContext, AsyncWindowContext, Context, Entity, + InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, + Task, TextAlign, UniformList, Window, }; use i18n::{shared_t, t}; use nostr_sdk::prelude::*; @@ -15,100 +15,40 @@ use smallvec::{smallvec, SmallVec}; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::input::{InputEvent, InputState, TextInput}; -use ui::modal::ModalButtonProps; -use ui::{h_flex, v_flex, ContextModal, IconName, Sizable, StyledExt}; +use ui::{h_flex, v_flex, ContextModal, IconName, Sizable}; -pub fn init(kind: Kind, window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| SetupRelay::new(kind, window, cx)) -} - -pub fn setup_nip17_relay(label: T) -> impl IntoElement -where - T: Into, -{ - div().child( - Button::new("setup-relays") - .icon(IconName::Info) - .label(label) - .warning() - .xsmall() - .rounded() - .on_click(move |_, window, cx| { - let view = cx.new(|cx| SetupRelay::new(Kind::InboxRelays, window, cx)); - let weak_view = view.downgrade(); - - window.open_modal(cx, move |modal, _window, _cx| { - let weak_view = weak_view.clone(); - - modal - .confirm() - .title(shared_t!("relays.modal")) - .child(view.clone()) - .button_props(ModalButtonProps::default().ok_text(t!("common.update"))) - .on_ok(move |_, window, cx| { - weak_view - .update(cx, |this, cx| { - this.set_relays(window, cx); - }) - .ok(); - // true to close the modal - false - }) - }) - }), - ) +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| SetupRelay::new(window, cx)) } +#[derive(Debug)] pub struct SetupRelay { input: Entity, - relays: Vec, error: Option, + + // All relays + relays: HashSet, + + // Event subscriptions _subscriptions: SmallVec<[Subscription; 1]>, + + // Background tasks _tasks: SmallVec<[Task<()>; 1]>, } impl SetupRelay { - pub fn new(kind: Kind, window: &mut Window, cx: &mut Context) -> Self { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com")); let mut subscriptions = smallvec![]; let mut tasks = smallvec![]; - let load_relay = cx.background_spawn(async move { - let client = nostr_client(); - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - let filter = Filter::new().kind(kind).author(public_key).limit(1); - - if let Some(event) = client.database().query(filter).await?.first() { - let relays: Vec = event - .tags - .iter() - .filter_map(|tag| tag.as_standardized()) - .filter_map(|tag| { - if let TagStandard::RelayMetadata { relay_url, .. } = tag { - Some(relay_url.to_owned()) - } else if let TagStandard::Relay(url) = tag { - Some(url.to_owned()) - } else { - None - } - }) - .collect(); - - Ok(relays) - } else { - Err(anyhow!("Not found.")) - } - }); - tasks.push( // Load user's relays in the local database cx.spawn_in(window, async move |this, cx| { - if let Ok(relays) = load_relay.await { + if let Ok(relays) = Self::load(cx).await { this.update(cx, |this, cx| { - this.relays = relays; + this.relays.extend(relays); cx.notify(); }) .ok(); @@ -131,35 +71,55 @@ impl SetupRelay { Self { input, - relays: vec![], + relays: HashSet::new(), error: None, _subscriptions: subscriptions, _tasks: tasks, } } + fn load(cx: &AsyncWindowContext) -> Task, Error>> { + cx.background_spawn(async move { + let client = nostr_client(); + 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 urls = nip17::extract_owned_relay_list(event).collect(); + Ok(urls) + } else { + Err(anyhow!("Not found.")) + } + }) + } + fn add(&mut self, window: &mut Window, cx: &mut Context) { let value = self.input.read(cx).value().to_string(); if !value.starts_with("ws") { + self.set_error("Relay URl is invalid", window, cx); return; } if let Ok(url) = RelayUrl::parse(&value) { - if !self.relays.contains(&url) { - self.relays.push(url); + if !self.relays.insert(url) { + self.input.update(cx, |this, cx| { + this.set_value("", window, cx); + }); + cx.notify(); } - - self.input.update(cx, |this, cx| { - this.set_value("", window, cx); - }); - - cx.notify(); + } else { + self.set_error("Relay URl is invalid", window, cx); } } - fn remove(&mut self, ix: usize, _window: &mut Window, cx: &mut Context) { - self.relays.remove(ix); + fn remove(&mut self, url: &RelayUrl, cx: &mut Context) { + self.relays.remove(url); cx.notify(); } @@ -173,12 +133,10 @@ impl SetupRelay { // Clear the error message after a delay cx.spawn_in(window, async move |this, cx| { cx.background_executor().timer(Duration::from_secs(2)).await; - cx.update(|_, cx| { - this.update(cx, |this, cx| { - this.error = None; - cx.notify(); - }) - .ok(); + + this.update(cx, |this, cx| { + this.error = None; + cx.notify(); }) .ok(); }) @@ -198,6 +156,9 @@ impl SetupRelay { let signer = client.signer().await?; let public_key = signer.get_public_key().await?; + let app_state = app_state(); + let gossip = app_state.gossip.read().await; + let tags: Vec = relays .iter() .map(|relay| Tag::relay(relay.clone())) @@ -205,21 +166,20 @@ impl SetupRelay { let event = EventBuilder::new(Kind::InboxRelays, "") .tags(tags) - .build(public_key) .sign(&signer) .await?; // Set messaging relays - client.send_event(&event).await?; + gossip.send_event_to_write_relays(&event).await?; // Connect to messaging relays for relay in relays.iter() { - _ = client.add_relay(relay).await; - _ = client.connect_relay(relay).await; + client.add_relay(relay).await.ok(); + client.connect_relay(relay).await.ok(); } // Fetch gift wrap events - let sub_id = app_state().gift_wrap_sub_id.clone(); + let sub_id = app_state.gift_wrap_sub_id.clone(); let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); if client @@ -259,38 +219,47 @@ impl SetupRelay { uniform_list( "relays", total, - cx.processor(move |_, range, _window, cx| { + cx.processor(move |_v, range, _window, cx| { let mut items = Vec::new(); for ix in range { - let item = relays.get(ix).map(|i: &RelayUrl| i.to_string()).unwrap(); - - items.push( - div().group("").w_full().h_9().py_0p5().child( + if let Some(url) = relays.iter().nth(ix) { + items.push( div() - .px_2() - .h_full() + .id(SharedString::from(url.to_string())) + .group("") .w_full() - .flex() - .items_center() - .justify_between() - .rounded(cx.theme().radius) - .bg(cx.theme().elevated_surface_background) - .text_xs() - .child(item) + .h_9() + .py_0p5() .child( - Button::new("remove_{ix}") - .icon(IconName::Close) - .xsmall() - .ghost() - .invisible() - .group_hover("", |this| this.visible()) - .on_click(cx.listener(move |this, _, window, cx| { - this.remove(ix, window, cx) - })), + div() + .px_2() + .h_full() + .w_full() + .flex() + .items_center() + .justify_between() + .rounded(cx.theme().radius) + .bg(cx.theme().elevated_surface_background) + .text_xs() + .child(SharedString::from(url.to_string())) + .child( + Button::new("remove_{ix}") + .icon(IconName::Close) + .xsmall() + .ghost() + .invisible() + .group_hover("", |this| this.visible()) + .on_click({ + let url = url.to_owned(); + cx.listener(move |this, _ev, _window, cx| { + this.remove(&url, cx); + }) + }), + ), ), - ), - ) + ) + } } items @@ -339,39 +308,6 @@ impl Render for SetupRelay { })), ), ) - .child( - h_flex() - .gap_2() - .child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) - .child(shared_t!("common.recommended")), - ) - .child(h_flex().gap_1().children({ - NIP17_RELAYS.iter().map(|&relay| { - div() - .id(relay) - .group("") - .py_0p5() - .px_1p5() - .text_xs() - .text_center() - .bg(cx.theme().secondary_background) - .hover(|this| this.bg(cx.theme().secondary_hover)) - .active(|this| this.bg(cx.theme().secondary_active)) - .rounded_full() - .child(relay) - .on_click(cx.listener(move |this, _, window, cx| { - this.input.update(cx, |this, cx| { - this.set_value(relay, window, cx); - }); - this.add(window, cx); - })) - }) - })), - ) .when_some(self.error.as_ref(), |this, error| { this.child( div() diff --git a/crates/coop/src/views/welcome.rs b/crates/coop/src/views/welcome.rs index e14668c6..61165670 100644 --- a/crates/coop/src/views/welcome.rs +++ b/crates/coop/src/views/welcome.rs @@ -69,7 +69,7 @@ impl Focusable for Welcome { } impl Render for Welcome { - fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div() .size_full() .flex() diff --git a/crates/registry/src/room.rs b/crates/registry/src/room.rs index 0cad19ca..ddba3fed 100644 --- a/crates/registry/src/room.rs +++ b/crates/registry/src/room.rs @@ -505,7 +505,7 @@ impl Room { continue; } - // Send the event to the relays + // Send the event to the messaging relays match client.send_event_to(urls, &event).await { Ok(output) => { let id = output.id().to_owned(); @@ -556,7 +556,7 @@ impl Room { if urls.is_empty() { reports.push(SendReport::new(public_key).not_found()); } else { - // Send the event to the relays + // Send the event to the messaging relays match client.send_event_to(urls, &event).await { Ok(output) => { reports.push(SendReport::new(public_key).status(output)); @@ -619,7 +619,7 @@ impl Room { if urls.is_empty() { resend_reports.push(SendReport::new(receiver).not_found()); } else { - // Send the event to the relays + // Send the event to the messaging relays match client.send_event_to(urls, &event).await { Ok(output) => { resend_reports.push(SendReport::new(receiver).status(output)); diff --git a/locales/app.yml b/locales/app.yml index 243e4adf..47706a2c 100644 --- a/locales/app.yml +++ b/locales/app.yml @@ -53,6 +53,12 @@ common: en: "Resend" seen_on: en: "Seen on" + default: + en: "Default" + use_default: + en: "Use default" + configure: + en: "Configure" auto_update: updating: @@ -176,13 +182,25 @@ login: logging_in: en: "Logging in..." -relays: +mailbox: + modal: + en: "Set Up Mailbox Relays to Continue" + description: + en: "By configuring Mailbox Relays, Coop can find where to get or send your events. If you are unsure, use the default option and modify it later." + write_label: + en: "Outbox Relays are used to publish your events. Other users will also look for your events there." + read_label: + en: "Inbox Relays are used to find events about you. Other users will publish the events they want you to see there." + +messaging: button: en: "Configure the Messaging Relays to receive messages" modal: en: "Set Up Messaging Relays" description: en: "In order to receive messages from others, you need to set up at least one Messaging Relay." + +relays: help_text: en: "Please add some relays." empty: