From 067f88dfa61ba13d7c455a6739f9eea91c28795a Mon Sep 17 00:00:00 2001 From: reya Date: Sat, 3 Jan 2026 20:39:18 +0700 Subject: [PATCH 01/20] wip --- Cargo.lock | 85 +-- crates/account/Cargo.toml | 22 - crates/account/src/lib.rs | 208 ------ crates/chat/Cargo.toml | 2 - crates/chat/src/lib.rs | 148 ++--- crates/chat/src/room.rs | 235 ++----- crates/chat_ui/Cargo.toml | 2 - crates/chat_ui/src/actions.rs | 5 - crates/chat_ui/src/lib.rs | 1 - crates/common/src/constants.rs | 3 +- crates/coop/Cargo.toml | 3 - crates/encryption/src/lib.rs | 682 ------------------- crates/encryption/src/signer.rs | 9 - crates/encryption_ui/Cargo.toml | 27 - crates/encryption_ui/src/lib.rs | 464 ------------- crates/relay_auth/Cargo.toml | 1 + crates/relay_auth/src/lib.rs | 105 +-- crates/state/Cargo.toml | 6 +- crates/state/src/event.rs | 46 ++ crates/state/src/gossip.rs | 103 +++ crates/state/src/identity.rs | 83 +++ crates/state/src/lib.rs | 684 ++++++++++++-------- crates/{encryption => state_old}/Cargo.toml | 17 +- crates/state_old/src/lib.rs | 388 +++++++++++ crates/{state => state_old}/src/storage.rs | 0 crates/{state => state_old}/src/tracker.rs | 0 26 files changed, 1212 insertions(+), 2117 deletions(-) delete mode 100644 crates/account/Cargo.toml delete mode 100644 crates/account/src/lib.rs delete mode 100644 crates/encryption/src/lib.rs delete mode 100644 crates/encryption/src/signer.rs delete mode 100644 crates/encryption_ui/Cargo.toml delete mode 100644 crates/encryption_ui/src/lib.rs create mode 100644 crates/state/src/event.rs create mode 100644 crates/state/src/gossip.rs create mode 100644 crates/state/src/identity.rs rename crates/{encryption => state_old}/Cargo.toml (71%) create mode 100644 crates/state_old/src/lib.rs rename crates/{state => state_old}/src/storage.rs (100%) rename crates/{state => state_old}/src/tracker.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index f430e0bb..5c039d7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,25 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "account" -version = "0.3.0" -dependencies = [ - "anyhow", - "common", - "gpui", - "log", - "nostr-sdk", - "serde", - "serde_json", - "settings", - "smallvec", - "smol", - "state", - "theme", - "ui", -] - [[package]] name = "adler2" version = "2.0.1" @@ -1025,10 +1006,8 @@ dependencies = [ name = "chat" version = "0.3.0" dependencies = [ - "account", "anyhow", "common", - "encryption", "flume", "futures", "fuzzy-matcher", @@ -1049,12 +1028,10 @@ dependencies = [ name = "chat_ui" version = "0.3.0" dependencies = [ - "account", "anyhow", "chat", "common", "emojis", - "encryption", "gpui", "gpui_tokio", "indexset", @@ -1308,15 +1285,12 @@ checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" name = "coop" version = "0.3.0" dependencies = [ - "account", "anyhow", "assets", "auto_update", "chat", "chat_ui", "common", - "encryption", - "encryption_ui", "futures", "gpui", "gpui_tokio", @@ -1825,49 +1799,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "encryption" -version = "0.3.0" -dependencies = [ - "account", - "anyhow", - "common", - "flume", - "futures", - "gpui", - "log", - "nostr-sdk", - "serde", - "serde_json", - "smallvec", - "smol", - "state", -] - -[[package]] -name = "encryption_ui" -version = "0.3.0" -dependencies = [ - "account", - "anyhow", - "common", - "encryption", - "futures", - "gpui", - "itertools 0.13.0", - "log", - "nostr-sdk", - "person", - "serde", - "serde_json", - "settings", - "smallvec", - "smol", - "state", - "theme", - "ui", -] - [[package]] name = "endi" version = "1.1.1" @@ -5198,6 +5129,7 @@ version = "0.3.0" dependencies = [ "anyhow", "common", + "flume", "gpui", "log", "nostr-sdk", @@ -6080,6 +6012,21 @@ dependencies = [ [[package]] name = "state" version = "0.3.0" +dependencies = [ + "anyhow", + "common", + "flume", + "gpui", + "log", + "nostr-lmdb", + "nostr-sdk", + "rustls", + "smol", +] + +[[package]] +name = "state_old" +version = "0.3.0" dependencies = [ "anyhow", "common", diff --git a/crates/account/Cargo.toml b/crates/account/Cargo.toml deleted file mode 100644 index a594b169..00000000 --- a/crates/account/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "account" -version.workspace = true -edition.workspace = true -publish.workspace = true - -[dependencies] -state = { path = "../state" } -settings = { path = "../settings" } -common = { path = "../common" } -theme = { path = "../theme" } -ui = { path = "../ui" } - -gpui.workspace = true -nostr-sdk.workspace = true - -anyhow.workspace = true -smallvec.workspace = true -smol.workspace = true -log.workspace = true -serde.workspace = true -serde_json.workspace = true diff --git a/crates/account/src/lib.rs b/crates/account/src/lib.rs deleted file mode 100644 index 92a088b5..00000000 --- a/crates/account/src/lib.rs +++ /dev/null @@ -1,208 +0,0 @@ -use std::time::Duration; - -use anyhow::Error; -use common::BOOTSTRAP_RELAYS; -use gpui::{App, AppContext, Context, Entity, Global, Task}; -use nostr_sdk::prelude::*; -use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; - -pub fn init(cx: &mut App) { - Account::set_global(cx.new(Account::new), cx); -} - -struct GlobalAccount(Entity); - -impl Global for GlobalAccount {} - -pub struct Account { - /// The public key of the account - public_key: Option, - - /// Status of the current user NIP-65 relays - pub nip65_status: Entity, - - /// Status of the current user NIP-17 relays - pub nip17_status: Entity, - - /// Tasks for asynchronous operations - _tasks: SmallVec<[Task<()>; 2]>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum RelayStatus { - #[default] - Initial, - NotSet, - Set, -} - -impl Account { - /// Retrieve the global account state - pub fn global(cx: &App) -> Entity { - cx.global::().0.clone() - } - - /// Check if the global account state exists - pub fn has_global(cx: &App) -> bool { - cx.has_global::() - } - - /// Remove the global account state - pub fn remove_global(cx: &mut App) { - cx.remove_global::(); - } - - /// Set the global account instance - fn set_global(state: Entity, cx: &mut App) { - cx.set_global(GlobalAccount(state)); - } - - /// Create a new account instance - fn new(cx: &mut Context) -> Self { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let nip65_status = cx.new(|_| RelayStatus::default()); - let nip17_status = cx.new(|_| RelayStatus::default()); - - let mut tasks = smallvec![]; - - tasks.push( - // Observe the nostr signer and set the public key when it sets - cx.spawn(async move |this, cx| { - let result = cx - .background_spawn(async move { Self::observe_signer(&client).await }) - .await; - - if let Some(public_key) = result { - this.update(cx, |this, cx| { - this.set_account(public_key, cx); - }) - .expect("Entity has been released") - } - }), - ); - - Self { - public_key: None, - nip65_status, - nip17_status, - _tasks: tasks, - } - } - - /// Observe the signer and return the public key when it sets - async fn observe_signer(client: &Client) -> Option { - let loop_duration = Duration::from_millis(800); - - loop { - if let Ok(signer) = client.signer().await { - if let Ok(public_key) = signer.get_public_key().await { - // Get current user's gossip relays - Self::get_gossip_relays(client, public_key).await.ok()?; - - return Some(public_key); - } - } - smol::Timer::after(loop_duration).await; - } - } - - /// Get gossip relays for a given public key - async fn get_gossip_relays(client: &Client, public_key: PublicKey) -> Result<(), Error> { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - - let filter = Filter::new() - .kind(Kind::RelayList) - .author(public_key) - .limit(1); - - // Subscribe to events from the bootstrapping relays - client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await?; - - Ok(()) - } - - /// Ensure the user has NIP-65 relays - async fn ensure_nip65_relays(client: &Client, public_key: PublicKey) -> Result { - let filter = Filter::new() - .kind(Kind::RelayList) - .author(public_key) - .limit(1); - - // Count the number of nip65 relays event in the database - let total = client.database().count(filter).await.unwrap_or(0); - - Ok(total > 0) - } - - /// Ensure the user has NIP-17 relays - async fn ensure_nip17_relays(client: &Client, public_key: PublicKey) -> Result { - let filter = Filter::new() - .kind(Kind::InboxRelays) - .author(public_key) - .limit(1); - - // Count the number of nip17 relays event in the database - let total = client.database().count(filter).await.unwrap_or(0); - - Ok(total > 0) - } - - /// Set the public key of the account - pub fn set_account(&mut self, public_key: PublicKey, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - // Update account's public key - self.public_key = Some(public_key); - - // Add background task - self._tasks.push( - // Verify user's nip65 and nip17 relays - cx.spawn(async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(5)).await; - - // Fetch the NIP-65 relays event in the local database - let ensure_nip65 = Self::ensure_nip65_relays(&client, public_key).await; - - // Fetch the NIP-17 relays event in the local database - let ensure_nip17 = Self::ensure_nip17_relays(&client, public_key).await; - - this.update(cx, |this, cx| { - this.nip65_status.update(cx, |this, cx| { - *this = match ensure_nip65 { - Ok(true) => RelayStatus::Set, - _ => RelayStatus::NotSet, - }; - cx.notify(); - }); - this.nip17_status.update(cx, |this, cx| { - *this = match ensure_nip17 { - Ok(true) => RelayStatus::Set, - _ => RelayStatus::NotSet, - }; - cx.notify(); - }); - }) - .expect("Entity has been released") - }), - ); - - cx.notify(); - } - - /// Check if the account entity has a public key - pub fn has_account(&self) -> bool { - self.public_key.is_some() - } - - /// Get the public key of the account - pub fn public_key(&self) -> PublicKey { - // This method is only called when user is logged in, so unwrap safely - self.public_key.unwrap() - } -} diff --git a/crates/chat/Cargo.toml b/crates/chat/Cargo.toml index 9dbb7157..0c2a4650 100644 --- a/crates/chat/Cargo.toml +++ b/crates/chat/Cargo.toml @@ -7,8 +7,6 @@ publish.workspace = true [dependencies] common = { path = "../common" } state = { path = "../state" } -account = { path = "../account" } -encryption = { path = "../encryption" } person = { path = "../person" } settings = { path = "../settings" } diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 42629138..c36accc7 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -5,20 +5,18 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; -use account::Account; use anyhow::{anyhow, Context as AnyhowContext, Error}; use common::{EventUtils, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT}; -use encryption::Encryption; use flume::Sender; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; -use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; pub use message::*; use nostr_sdk::prelude::*; pub use room::*; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; -use state::{initialized_at, NostrRegistry, GIFTWRAP_SUBSCRIPTION}; +use state::{tracker, NostrRegistry, GIFTWRAP_SUBSCRIPTION}; mod message; mod room; @@ -35,16 +33,10 @@ impl Global for GlobalChatRegistry {} #[derive(Debug)] pub struct ChatRegistry { /// Collection of all chat rooms - pub rooms: Vec>, + rooms: Vec>, /// Loading status of the registry - pub loading: bool, - - /// Async task for handling notifications - handle_notifications: Task<()>, - - /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, + loading: bool, /// Tasks for asynchronous operations _tasks: SmallVec<[Task<()>; 4]>, @@ -79,53 +71,30 @@ impl ChatRegistry { /// Create a new chat registry instance fn new(cx: &mut Context) -> Self { - let encryption = Encryption::global(cx); - let encryption_key = encryption.read(cx).encryption.clone(); - let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); + // A flag to indicate if the registry is loading let status = Arc::new(AtomicBool::new(true)); + + // Channel for communication between nostr and gpui let (tx, rx) = flume::bounded::(2048); - let handle_notifications = cx.background_spawn({ - let client = nostr.read(cx).client(); - let status = Arc::clone(&status); - let tx = tx.clone(); - let signer: Option> = None; - - async move { Self::handle_notifications(&client, &signer, &tx, &status).await } - }); - - let mut subscriptions = smallvec![]; let mut tasks = smallvec![]; - subscriptions.push( - // Observe the encryption global state - cx.observe(&encryption_key, { + tasks.push( + // Handle nostr notifications + cx.background_spawn({ + let client = client.clone(); let status = Arc::clone(&status); let tx = tx.clone(); - move |this, state, cx| { - if let Some(signer) = state.read(cx).clone() { - this.handle_notifications = cx.background_spawn({ - let client = nostr.read(cx).client(); - let status = Arc::clone(&status); - let tx = tx.clone(); - let signer = Some(signer); - - async move { - Self::handle_notifications(&client, &signer, &tx, &status).await - } - }); - cx.notify(); - } - } + async move { Self::handle_notifications(&client, &status, &tx).await } }), ); tasks.push( - // Handle unwrapping status + // Handle unwrapping progress cx.background_spawn( async move { Self::handle_unwrapping(&client, &status, &tx).await }, ), @@ -140,20 +109,20 @@ impl ChatRegistry { this.update(cx, |this, cx| { this.new_message(message, cx); }) - .expect("Entity has been released"); + .ok(); } Signal::Eose => { this.update(cx, |this, cx| { this.get_rooms(cx); }) - .expect("Entity has been released"); + .ok(); } Signal::Loading(status) => { this.update(cx, |this, cx| { this.set_loading(status, cx); this.get_rooms(cx); }) - .expect("Entity has been released"); + .ok(); } }; } @@ -163,21 +132,12 @@ impl ChatRegistry { Self { rooms: vec![], loading: true, - handle_notifications, - _subscriptions: subscriptions, _tasks: tasks, } } - async fn handle_notifications( - client: &Client, - signer: &Option, - tx: &Sender, - status: &Arc, - ) where - T: NostrSigner, - { - let initialized_at = initialized_at(); + async fn handle_notifications(client: &Client, loading: &Arc, tx: &Sender) { + let initialized_at = Timestamp::now(); let subscription_id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); let mut notifications = client.notifications(); @@ -203,13 +163,13 @@ impl ChatRegistry { } // Extract the rumor from the gift wrap event - match Self::extract_rumor(client, signer, event.as_ref()).await { + match Self::extract_rumor(client, event.as_ref()).await { Ok(rumor) => { // Get all public keys public_keys.extend(rumor.all_pubkeys()); let limit_reached = public_keys.len() >= METADATA_BATCH_LIMIT; - let done = !status.load(Ordering::Acquire) && !public_keys.is_empty(); + let done = !loading.load(Ordering::Acquire) && !public_keys.is_empty(); // Get metadata for all public keys if the limit is reached if limit_reached || done { @@ -218,17 +178,24 @@ impl ChatRegistry { Self::get_metadata(client, public_keys).await.ok(); } - match &rumor.created_at >= initialized_at { + match rumor.created_at >= initialized_at { true => { - let new_message = NewMessage::new(event.id, rumor); - let signal = Signal::Message(new_message); + let sent_by_coop = { + let tracker = tracker().read().await; + tracker.is_sent_by_coop(&event.id) + }; - if let Err(e) = tx.send_async(signal).await { - log::error!("Failed to send signal: {}", e); + if !sent_by_coop { + let new_message = NewMessage::new(event.id, rumor); + let signal = Signal::Message(new_message); + + if let Err(e) = tx.send_async(signal).await { + log::error!("Failed to send signal: {}", e); + } } } false => { - status.store(true, Ordering::Release); + loading.store(true, Ordering::Release); } } } @@ -530,7 +497,7 @@ impl ChatRegistry { pub fn new_message(&mut self, message: NewMessage, cx: &mut Context) { let id = message.rumor.uniq_id(); let author = message.rumor.pubkey; - let account = Account::global(cx); + let nostr = NostrRegistry::global(cx); if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) { let is_new_event = message.rumor.created_at > room.read(cx).created_at; @@ -544,7 +511,7 @@ impl ChatRegistry { } // Set this room is ongoing if the new message is from current user - if author == account.read(cx).public_key() { + if author == nostr.read(cx).identity(cx).public_key() { this.set_ongoing(cx); } @@ -566,21 +533,14 @@ impl ChatRegistry { } // Unwraps a gift-wrapped event and processes its contents. - async fn extract_rumor( - client: &Client, - signer: &Option, - gift_wrap: &Event, - ) -> Result - where - T: NostrSigner, - { + async fn extract_rumor(client: &Client, gift_wrap: &Event) -> Result { // Try to get cached rumor first if let Ok(event) = Self::get_rumor(client, gift_wrap.id).await { return Ok(event); } // Try to unwrap with the available signer - let unwrapped = Self::try_unwrap(client, signer, gift_wrap).await?; + let unwrapped = Self::try_unwrap(client, gift_wrap).await?; let mut rumor_unsigned = unwrapped.rumor; // Generate event id for the rumor if it doesn't have one @@ -593,39 +553,7 @@ impl ChatRegistry { } // Helper method to try unwrapping with different signers - async fn try_unwrap( - client: &Client, - signer: &Option, - gift_wrap: &Event, - ) -> Result - where - T: NostrSigner, - { - if let Some(custom_signer) = signer.as_ref() { - if let Ok(seal) = custom_signer - .nip44_decrypt(&gift_wrap.pubkey, &gift_wrap.content) - .await - { - let seal: Event = Event::from_json(seal)?; - seal.verify_with_ctx(&SECP256K1)?; - - // Decrypt the rumor - // TODO: verify the sender - let rumor = custom_signer - .nip44_decrypt(&seal.pubkey, &seal.content) - .await?; - - // Construct the unsigned event - let rumor = UnsignedEvent::from_json(rumor)?; - - // Return the unwrapped gift - return Ok(UnwrappedGift { - sender: rumor.pubkey, - rumor, - }); - } - } - + async fn try_unwrap(client: &Client, gift_wrap: &Event) -> Result { let signer = client.signer().await?; let unwrapped = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await?; diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 0ad78789..7e4b1446 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -3,43 +3,16 @@ use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::time::Duration; -use account::Account; -use anyhow::{anyhow, Error}; +use anyhow::Error; use common::{EventUtils, RenderedProfile}; -use encryption::{Encryption, SignerKind}; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; use itertools::Itertools; use nostr_sdk::prelude::*; use person::PersonRegistry; -use state::NostrRegistry; +use state::{tracker, NostrRegistry}; const SEND_RETRY: usize = 10; -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub struct SendOptions { - pub backup: bool, - pub signer_kind: SignerKind, -} - -impl SendOptions { - pub fn new() -> Self { - Self { - backup: true, - signer_kind: SignerKind::default(), - } - } - - pub fn backup(&self) -> bool { - self.backup - } -} - -impl Default for SendOptions { - fn default() -> Self { - Self::new() - } -} - #[derive(Debug, Clone)] pub struct SendReport { pub receiver: PublicKey, @@ -248,6 +221,19 @@ impl Room { self.members.clone() } + /// Returns the members of the room with their messaging relays + pub fn members_with_relays(&self, cx: &App) -> Vec<(PublicKey, Vec)> { + let nostr = NostrRegistry::global(cx); + let mut result = vec![]; + + for member in self.members.iter() { + let messaging_relays = nostr.read(cx).messaging_relays(member, cx); + result.push((member.to_owned(), messaging_relays)); + } + + result + } + /// Checks if the room has more than two members (group) pub fn is_group(&self) -> bool { self.members.len() > 2 @@ -276,8 +262,8 @@ impl Room { /// Display member is always different from the current user. pub fn display_member(&self, cx: &App) -> Profile { let persons = PersonRegistry::global(cx); - let account = Account::global(cx); - let public_key = account.read(cx).public_key(); + let nostr = NostrRegistry::global(cx); + let public_key = nostr.read(cx).identity(cx).public_key(); let target_member = self .members @@ -381,12 +367,9 @@ impl Room { /// Create a new message event (unsigned) pub fn create_message(&self, content: &str, replies: &[EventId], cx: &App) -> UnsignedEvent { let nostr = NostrRegistry::global(cx); - let gossip = nostr.read(cx).gossip(); - let read_gossip = gossip.read_blocking(); // Get current user - let account = Account::global(cx); - let public_key = account.read(cx).public_key(); + let public_key = nostr.read(cx).identity(cx).public_key(); // Get room's subject let subject = self.subject.clone(); @@ -398,7 +381,7 @@ impl Room { // NOTE: current user will be removed from the list of receivers for member in self.members.iter() { // Get relay hint if available - let relay_url = read_gossip.messaging_relays(member).first().cloned(); + let relay_url = nostr.read(cx).relay_hint(member, cx); // Construct a public key tag with relay hint let tag = TagStandard::PublicKey { @@ -449,98 +432,63 @@ impl Room { pub fn send_message( &self, rumor: &UnsignedEvent, - opts: &SendOptions, cx: &App, ) -> Task, Error>> { - let encryption = Encryption::global(cx); - let encryption_key = encryption.read(cx).encryption_key(cx); - let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let gossip = nostr.read(cx).gossip(); - let tracker = nostr.read(cx).tracker(); + + // Get current user's public key and relays + let current_user = nostr.read(cx).identity(cx).public_key(); + let current_user_relays = nostr.read(cx).messaging_relays(¤t_user, cx); let rumor = rumor.to_owned(); - let opts = opts.to_owned(); - // Get all members - let mut members = self.members(); + // Get all members and their messaging relays + let mut members = self.members_with_relays(cx); cx.background_spawn(async move { - let signer_kind = opts.signer_kind; - let gossip = gossip.read().await; - - // Get current user's signer and public key - let user_signer = client.signer().await?; - let user_pubkey = user_signer.get_public_key().await?; - - // Get the encryption public key - let encryption_pubkey = if let Some(signer) = encryption_key.as_ref() { - signer.get_public_key().await.ok() - } else { - None - }; + let signer = client.signer().await?; // Remove the current user's public key from the list of receivers // the current user will be handled separately - members.retain(|&pk| pk != user_pubkey); - - // Determine the signer will be used based on the provided options - let signer = Self::select_signer(&opts.signer_kind, user_signer, encryption_key)?; + members.retain(|(this, _)| this != ¤t_user); // Collect the send reports let mut reports: Vec = vec![]; - for member in members.into_iter() { - // Get user's messaging relays - let urls = gossip.messaging_relays(&member); - // Get user's encryption public key if available - let encryption = gossip.announcement(&member).map(|a| a.public_key()); - + for (receiver, relays) in members.into_iter() { // Check if there are any relays to send the message to - if urls.is_empty() { - reports.push(SendReport::new(member).relays_not_found()); + if relays.is_empty() { + reports.push(SendReport::new(receiver).relays_not_found()); continue; } - // Skip sending if using encryption signer but receiver's encryption keys not found - if encryption.is_none() && matches!(signer_kind, SignerKind::Encryption) { - reports.push(SendReport::new(member).device_not_found()); - continue; + // Ensure relay connection + for url in relays.iter() { + client.add_relay(url).await?; + client.connect_relay(url).await?; } - // Ensure connections to the relays - gossip.ensure_connections(&client, &urls).await; - - // Determine the receiver based on the signer kind - let receiver = Self::select_receiver(&signer_kind, member, encryption)?; - // Construct the gift wrap event - let event = EventBuilder::gift_wrap( - &signer, - &receiver, - rumor.clone(), - vec![Tag::public_key(member)], - ) - .await?; + let event = + EventBuilder::gift_wrap(&signer, &receiver, rumor.clone(), vec![]).await?; // Send the gift wrap event to the messaging relays - match client.send_event_to(urls, &event).await { + match client.send_event_to(relays, &event).await { Ok(output) => { let id = output.id().to_owned(); let auth = output.failed.iter().any(|(_, s)| s.starts_with("auth-")); let report = SendReport::new(receiver).status(output); + let tracker = tracker().read().await; if auth { // Wait for authenticated and resent event successfully for attempt in 0..=SEND_RETRY { - let tracker = tracker.read().await; - let ids = tracker.resent_ids(); - // Check if event was successfully resent - if let Some(output) = ids.iter().find(|e| e.id() == &id).cloned() { - let output = SendReport::new(receiver).status(output); - reports.push(output); + if tracker.is_sent_by_coop(&id) { + let output = Output::new(id); + let report = SendReport::new(receiver).status(output); + reports.push(report); break; } @@ -562,55 +510,35 @@ impl Room { } } - // Return early if the user disabled backup. - // - // Coop will not send a gift wrap event to the current user. - if !opts.backup() { - return Ok(reports); - } - - // Skip sending if using encryption signer but receiver's encryption keys not found - if encryption_pubkey.is_none() && matches!(signer_kind, SignerKind::Encryption) { - reports.push(SendReport::new(user_pubkey).device_not_found()); - return Ok(reports); - } - - // Determine the receiver based on the signer kind - let receiver = Self::select_receiver(&signer_kind, user_pubkey, encryption_pubkey)?; - // Construct the gift-wrapped event - let event = EventBuilder::gift_wrap( - &signer, - &receiver, - rumor.clone(), - vec![Tag::public_key(user_pubkey)], - ) - .await?; + let event = + EventBuilder::gift_wrap(&signer, ¤t_user, rumor.clone(), vec![]).await?; // Only send a backup message to current user if sent successfully to others if reports.iter().all(|r| r.is_sent_success()) { - let urls = gossip.messaging_relays(&user_pubkey); - // Check if there are any relays to send the event to - if urls.is_empty() { - reports.push(SendReport::new(user_pubkey).relays_not_found()); + if current_user_relays.is_empty() { + reports.push(SendReport::new(current_user).relays_not_found()); return Ok(reports); } - // Ensure connections to the relays - gossip.ensure_connections(&client, &urls).await; + // Ensure relay connection + for url in current_user_relays.iter() { + client.add_relay(url).await?; + client.connect_relay(url).await?; + } // Send the event to the messaging relays - match client.send_event_to(urls, &event).await { + match client.send_event_to(current_user_relays, &event).await { Ok(output) => { - reports.push(SendReport::new(user_pubkey).status(output)); + reports.push(SendReport::new(current_user).status(output)); } Err(e) => { - reports.push(SendReport::new(user_pubkey).error(e.to_string())); + reports.push(SendReport::new(current_user).error(e.to_string())); } } } else { - reports.push(SendReport::new(user_pubkey).on_hold(event)); + reports.push(SendReport::new(current_user).on_hold(event)); } Ok(reports) @@ -625,10 +553,8 @@ impl Room { ) -> Task, Error>> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let gossip = nostr.read(cx).gossip(); cx.background_spawn(async move { - let gossip = gossip.read().await; let mut resend_reports = vec![]; for report in reports.into_iter() { @@ -657,23 +583,13 @@ impl Room { // Process the on hold event if it exists if let Some(event) = report.on_hold { - let urls = gossip.messaging_relays(&receiver); - - // Check if there are any relays to send the event to - if urls.is_empty() { - resend_reports.push(SendReport::new(receiver).relays_not_found()); - } else { - // Ensure connections to the relays - gossip.ensure_connections(&client, &urls).await; - - // 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)); - } - Err(e) => { - resend_reports.push(SendReport::new(receiver).error(e.to_string())); - } + // Send the event to the messaging relays + match client.send_event(&event).await { + Ok(output) => { + resend_reports.push(SendReport::new(receiver).status(output)); + } + Err(e) => { + resend_reports.push(SendReport::new(receiver).error(e.to_string())); } } } @@ -682,31 +598,4 @@ impl Room { Ok(resend_reports) }) } - - fn select_signer(kind: &SignerKind, user: T, encryption: Option) -> Result - where - T: NostrSigner, - { - match kind { - SignerKind::Encryption => { - Ok(encryption.ok_or_else(|| anyhow!("No encryption key found"))?) - } - SignerKind::User => Ok(user), - SignerKind::Auto => Ok(encryption.unwrap_or(user)), - } - } - - fn select_receiver( - kind: &SignerKind, - member: PublicKey, - encryption: Option, - ) -> Result { - match kind { - SignerKind::Encryption => { - Ok(encryption.ok_or_else(|| anyhow!("Receiver's encryption key not found"))?) - } - SignerKind::User => Ok(member), - SignerKind::Auto => Ok(encryption.unwrap_or(member)), - } - } } diff --git a/crates/chat_ui/Cargo.toml b/crates/chat_ui/Cargo.toml index 9119b98d..bff3e8da 100644 --- a/crates/chat_ui/Cargo.toml +++ b/crates/chat_ui/Cargo.toml @@ -9,8 +9,6 @@ state = { path = "../state" } ui = { path = "../ui" } theme = { path = "../theme" } common = { path = "../common" } -account = { path = "../account" } -encryption = { path = "../encryption" } person = { path = "../person" } chat = { path = "../chat" } settings = { path = "../settings" } diff --git a/crates/chat_ui/src/actions.rs b/crates/chat_ui/src/actions.rs index 878bc9c8..bea282e4 100644 --- a/crates/chat_ui/src/actions.rs +++ b/crates/chat_ui/src/actions.rs @@ -1,4 +1,3 @@ -use encryption::SignerKind; use gpui::Action; use nostr_sdk::prelude::*; use serde::Deserialize; @@ -7,10 +6,6 @@ use serde::Deserialize; #[action(namespace = chat, no_json)] pub struct SeenOn(pub EventId); -#[derive(Action, Clone, PartialEq, Eq, Deserialize)] -#[action(namespace = chat, no_json)] -pub struct SetSigner(pub SignerKind); - /// Define a open public key action #[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)] #[action(namespace = pubkey, no_json)] diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index bd4ccd58..aef827d9 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -4,7 +4,6 @@ use std::time::Duration; pub use actions::*; use chat::{Message, RenderedMessage, Room, RoomKind, RoomSignal, SendOptions, SendReport}; use common::{nip96_upload, RenderedProfile, RenderedTimestamp}; -use encryption::SignerKind; use gpui::prelude::FluentBuilder; use gpui::{ div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext, diff --git a/crates/common/src/constants.rs b/crates/common/src/constants.rs index 5e12fb5a..391752bf 100644 --- a/crates/common/src/constants.rs +++ b/crates/common/src/constants.rs @@ -2,12 +2,11 @@ pub const CLIENT_NAME: &str = "Coop"; pub const APP_ID: &str = "su.reya.coop"; /// Bootstrap Relays. -pub const BOOTSTRAP_RELAYS: [&str; 5] = [ +pub const BOOTSTRAP_RELAYS: [&str; 4] = [ "wss://relay.damus.io", "wss://relay.primal.net", "wss://relay.nos.social", "wss://user.kindpag.es", - "wss://purplepag.es", ]; /// Search Relays. diff --git a/crates/coop/Cargo.toml b/crates/coop/Cargo.toml index d6d8d754..df8e9bad 100644 --- a/crates/coop/Cargo.toml +++ b/crates/coop/Cargo.toml @@ -38,9 +38,6 @@ chat = { path = "../chat" } chat_ui = { path = "../chat_ui" } settings = { path = "../settings" } auto_update = { path = "../auto_update" } -account = { path = "../account" } -encryption = { path = "../encryption" } -encryption_ui = { path = "../encryption_ui" } person = { path = "../person" } relay_auth = { path = "../relay_auth" } diff --git a/crates/encryption/src/lib.rs b/crates/encryption/src/lib.rs deleted file mode 100644 index 857f863e..00000000 --- a/crates/encryption/src/lib.rs +++ /dev/null @@ -1,682 +0,0 @@ -use std::collections::HashSet; -use std::sync::Arc; -use std::time::Duration; - -use account::Account; -use anyhow::{anyhow, Context as AnyhowContext, Error}; -use common::app_name; -use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; -use nostr_sdk::prelude::*; -pub use signer::*; -use smallvec::{smallvec, SmallVec}; -use state::{Announcement, NostrRegistry}; - -mod signer; - -pub fn init(cx: &mut App) { - Encryption::set_global(cx.new(Encryption::new), cx); -} - -struct GlobalEncryption(Entity); - -impl Global for GlobalEncryption {} - -pub struct Encryption { - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - /// - /// Client Signer that used for communication between devices - client_signer: Entity>>, - - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - /// - /// Encryption Key used for encryption and decryption instead of the user's identity - pub encryption: Entity>>, - - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - /// - /// Encryption Key announcement - announcement: Option>, - - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - /// - /// Requests for encryption keys from other devices - requests: Entity>, - - /// Async task for handling notifications - handle_notifications: Option>, - - /// Async task for handling requests - handle_requests: Option>, - - /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 2]>, - - /// Tasks for asynchronous operations - _tasks: SmallVec<[Task<()>; 1]>, -} - -impl Encryption { - /// Retrieve the global encryption state - pub fn global(cx: &App) -> Entity { - cx.global::().0.clone() - } - - /// Set the global encryption instance - fn set_global(state: Entity, cx: &mut App) { - cx.set_global(GlobalEncryption(state)); - } - - /// Create a new encryption instance - fn new(cx: &mut Context) -> Self { - let account = Account::global(cx); - - let requests = cx.new(|_| HashSet::default()); - let encryption = cx.new(|_| None); - let client_signer = cx.new(|_| None); - - let mut subscriptions = smallvec![]; - - subscriptions.push( - // Observe the account state - cx.observe(&account, |this, state, cx| { - if state.read(cx).has_account() && this.client_signer.read(cx).is_none() { - this.get_client(cx); - } - }), - ); - - subscriptions.push( - // Observe the client signer state - cx.observe(&client_signer, |this, state, cx| { - if state.read(cx).is_some() { - this.get_announcement(cx); - } - }), - ); - - subscriptions.push( - // Observe the encryption signer state - cx.observe(&encryption, |this, state, cx| { - if state.read(cx).is_some() { - this._tasks.push(this.resubscribe_messages(cx)); - } - }), - ); - - Self { - requests, - client_signer, - encryption, - announcement: None, - handle_notifications: None, - handle_requests: None, - _subscriptions: subscriptions, - _tasks: smallvec![], - } - } - - /// Encrypt and store a key in the local database. - async fn set_keys(client: &Client, kind: T, value: String) -> Result<(), Error> - where - T: Into, - { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - // Encrypt the value - let content = signer.nip44_encrypt(&public_key, value.as_ref()).await?; - - // Construct the application data event - let event = EventBuilder::new(Kind::ApplicationSpecificData, content) - .tag(Tag::identifier(format!("coop:{}", kind.into()))) - .build(public_key) - .sign(&Keys::generate()) - .await?; - - // Save the event to the database - client.database().save_event(&event).await?; - - Ok(()) - } - - /// Get and decrypt a key from the local database. - async fn get_keys(client: &Client, kind: T) -> Result - where - T: Into, - { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .identifier(format!("coop:{}", kind.into())); - - if let Some(event) = client.database().query(filter).await?.first() { - let content = signer.nip44_decrypt(&public_key, &event.content).await?; - let secret = SecretKey::parse(&content)?; - let keys = Keys::new(secret); - - Ok(keys) - } else { - Err(anyhow!("Key not found")) - } - } - - /// Get the client keys from the database - fn get_client(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - self._tasks.push( - // Run in the main thread - cx.spawn(async move |this, cx| { - match Self::get_keys(&client, "client").await { - Ok(keys) => { - this.update(cx, |this, cx| { - this.set_client(Arc::new(keys), cx); - }) - .expect("Entity has been released"); - } - Err(_) => { - let keys = Keys::generate(); - let secret = keys.secret_key().to_secret_hex(); - - // Store the key in the database for future use - Self::set_keys(&client, "client", secret).await.ok(); - - // Update global state - this.update(cx, |this, cx| { - this.set_client(Arc::new(keys), cx); - }) - .expect("Entity has been released"); - } - } - }), - ) - } - - /// Get the announcement from the database - fn get_announcement(&mut self, cx: &mut Context) { - let task = self._get_announcement(cx); - let delay = Duration::from_secs(5); - - self._tasks.push( - // Run task in the background - cx.spawn(async move |this, cx| { - cx.background_executor().timer(delay).await; - - if let Ok(announcement) = task.await { - this.update(cx, |this, cx| { - this.load_encryption(&announcement, cx); - // Set the announcement - this.announcement = Some(Arc::new(announcement)); - cx.notify(); - }) - .expect("Entity has been released"); - } - }), - ); - } - - fn _get_announcement(&self, cx: &App) -> Task> { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - cx.background_spawn(async move { - let user_signer = client.signer().await?; - let public_key = user_signer.get_public_key().await?; - - let filter = Filter::new() - .kind(Kind::Custom(10044)) - .author(public_key) - .limit(1); - - if let Some(event) = client.database().query(filter).await?.first() { - Ok(NostrRegistry::extract_announcement(event)?) - } else { - Err(anyhow!("Announcement not found")) - } - }) - } - - /// Load the encryption key that stored in the database - /// - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - fn load_encryption(&mut self, announcement: &Announcement, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let n = announcement.public_key(); - - cx.spawn(async move |this, cx| { - let result = Self::get_keys(&client, "encryption").await; - - this.update(cx, |this, cx| { - if let Ok(keys) = result { - if keys.public_key() == n { - this.set_encryption(Arc::new(keys), cx); - this.listen_request(cx); - } - } - this.load_response(cx); - }) - .expect("Entity has been released"); - }) - .detach(); - } - - pub fn load_response(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - // Get the client signer - let Some(client_signer) = self.client_signer.read(cx).clone() else { - return; - }; - - let task: Task> = cx.background_spawn(async move { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - let filter = Filter::new() - .author(public_key) - .kind(Kind::Custom(4455)) - .limit(1); - - if let Some(event) = client.database().query(filter).await?.first_owned() { - let response = NostrRegistry::extract_response(&client, &event).await?; - - // Decrypt the payload using the client signer - let decrypted = client_signer - .nip44_decrypt(&response.public_key(), response.payload()) - .await?; - - // Construct the encryption keys - let secret = SecretKey::parse(&decrypted)?; - let keys = Keys::new(secret); - - return Ok(keys); - } - - Err(anyhow!("not found")) - }); - - cx.spawn(async move |this, cx| { - match task.await { - Ok(keys) => { - this.update(cx, |this, cx| { - this.set_encryption(Arc::new(keys), cx); - }) - .expect("Entity has been released"); - } - Err(e) => { - log::warn!("Failed to load encryption response: {e}"); - } - }; - }) - .detach(); - } - - /// Listen for the encryption key request from other devices - /// - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - pub fn listen_request(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let (tx, rx) = flume::bounded::(50); - - let task: Task> = cx.background_spawn({ - let client = nostr.read(cx).client(); - - async move { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - let id = SubscriptionId::new("listen-request"); - - let filter = Filter::new() - .author(public_key) - .kind(Kind::Custom(4454)) - .since(Timestamp::now()); - - // Unsubscribe from the previous subscription - client.unsubscribe(&id).await; - - // Subscribe to the new subscription - client.subscribe_with_id(id, filter, None).await?; - - Ok(()) - } - }); - - // Run this task and finish in the background - task.detach(); - - // Handle notifications - self.handle_notifications = Some(cx.background_spawn(async move { - let mut notifications = client.notifications(); - let mut processed_events = HashSet::new(); - - while let Ok(notification) = notifications.recv().await { - let RelayPoolNotification::Message { message, .. } = notification else { - // Skip if the notification is not a message - continue; - }; - - if let RelayMessage::Event { event, .. } = message { - if !processed_events.insert(event.id) { - // Skip if the event has already been processed - continue; - } - - if event.kind != Kind::Custom(4454) { - // Skip if the event is not a encryption events - continue; - }; - - if NostrRegistry::is_self_authored(&client, &event).await { - if let Ok(announcement) = NostrRegistry::extract_announcement(&event) { - tx.send_async(announcement).await.ok(); - } - } - } - } - })); - - // Handle requests - self.handle_requests = Some(cx.spawn(async move |this, cx| { - while let Ok(request) = rx.recv_async().await { - this.update(cx, |this, cx| { - this.set_request(request, cx); - }) - .expect("Entity has been released"); - } - })); - } - - /// Overwrite the encryption key - /// - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - pub fn new_encryption(&self, cx: &App) -> Task> { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let gossip = nostr.read(cx).gossip(); - - let keys = Keys::generate(); - let public_key = keys.public_key(); - let secret = keys.secret_key().to_secret_hex(); - - // Create a task announce the encryption key - cx.background_spawn(async move { - // Store the encryption key to the database - Self::set_keys(&client, "encryption", secret).await?; - - let signer = client.signer().await?; - let signer_pubkey = signer.get_public_key().await?; - let gossip = gossip.read().await; - let write_relays = gossip.outbox_relays(&signer_pubkey); - - // Ensure connections to the write relays - gossip.ensure_connections(&client, &write_relays).await; - - // Construct the announcement event - let event = EventBuilder::new(Kind::Custom(10044), "") - .tags(vec![ - Tag::client(app_name()), - Tag::custom(TagKind::custom("n"), vec![public_key]), - ]) - .build(signer_pubkey) - .sign(&signer) - .await?; - - // Send the announcement event to user's relays - client.send_event_to(write_relays, &event).await?; - - Ok(keys) - }) - } - - /// Send a request for encryption key from other clients - /// - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - pub fn send_request(&self, cx: &App) -> Task, Error>> { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let gossip = nostr.read(cx).gossip(); - - // Get the client signer - let Some(client_signer) = self.client_signer.read(cx).clone() else { - return Task::ready(Err(anyhow!("Client Signer is required"))); - }; - - cx.background_spawn(async move { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - let client_pubkey = client_signer.get_public_key().await?; - - // Get the encryption key approval response from the database first - let filter = Filter::new() - .kind(Kind::Custom(4455)) - .author(public_key) - .pubkey(client_pubkey) - .limit(1); - - match client.database().query(filter).await?.first_owned() { - Some(event) => { - let root_device = event - .tags - .find(TagKind::custom("P")) - .and_then(|tag| tag.content()) - .and_then(|content| PublicKey::parse(content).ok()) - .context("Invalid event's tags")?; - - let payload = event.content.as_str(); - let decrypted = client_signer.nip44_decrypt(&root_device, payload).await?; - - let secret = SecretKey::from_hex(&decrypted)?; - let keys = Keys::new(secret); - - Ok(Some(keys)) - } - None => { - let gossip = gossip.read().await; - let write_relays = gossip.outbox_relays(&public_key); - - // Ensure connections to the write relays - gossip.ensure_connections(&client, &write_relays).await; - - // Construct encryption keys request event - let event = EventBuilder::new(Kind::Custom(4454), "") - .tags(vec![ - Tag::client(app_name()), - Tag::custom(TagKind::custom("pubkey"), vec![client_pubkey]), - ]) - .sign(&signer) - .await?; - - // Send a request for encryption keys from other devices - client.send_event_to(&write_relays, &event).await?; - - // Create a unique ID to control the subscription later - let subscription_id = SubscriptionId::new("listen-response"); - - let filter = Filter::new() - .kind(Kind::Custom(4455)) - .author(public_key) - .since(Timestamp::now()); - - // Subscribe to the approval response event - client - .subscribe_with_id_to(&write_relays, subscription_id, filter, None) - .await?; - - Ok(None) - } - } - }) - } - - /// Send the approval response event - /// - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - pub fn send_response(&self, target: PublicKey, cx: &App) -> Task> { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let gossip = nostr.read(cx).gossip(); - - // Get the client signer - let Some(client_signer) = self.client_signer.read(cx).clone() else { - return Task::ready(Err(anyhow!("Client Signer is required"))); - }; - - cx.background_spawn(async move { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - let gossip = gossip.read().await; - let write_relays = gossip.outbox_relays(&public_key); - - // Ensure connections to the write relays - gossip.ensure_connections(&client, &write_relays).await; - - let encryption = Self::get_keys(&client, "encryption").await?; - let client_pubkey = client_signer.get_public_key().await?; - - // Encrypt the encryption keys with the client's signer - let payload = client_signer - .nip44_encrypt(&target, &encryption.secret_key().to_secret_hex()) - .await?; - - // Construct the response event - // - // P tag: the current client's public key - // p tag: the requester's public key - let event = EventBuilder::new(Kind::Custom(4455), payload) - .tags(vec![ - Tag::custom(TagKind::custom("P"), vec![client_pubkey]), - Tag::public_key(target), - ]) - .build(public_key) - .sign(&signer) - .await?; - - // Send the response event to the user's relay list - client.send_event_to(write_relays, &event).await?; - - Ok(()) - }) - } - - /// Wait for the approval response event - /// - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - pub fn wait_for_approval(&self, cx: &App) -> Task> { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - // Get the client signer - let Some(client_signer) = self.client_signer.read(cx).clone() else { - return Task::ready(Err(anyhow!("Client Signer is required"))); - }; - - cx.background_spawn(async move { - let mut notifications = client.notifications(); - let mut processed_events = HashSet::new(); - - while let Ok(notification) = notifications.recv().await { - let RelayPoolNotification::Message { message, .. } = notification else { - // Skip non-message notifications - continue; - }; - - if let RelayMessage::Event { event, .. } = message { - if !processed_events.insert(event.id) { - // Skip if the event has already been processed - continue; - } - - if event.kind != Kind::Custom(4455) { - // Skip non-response events - continue; - } - - if let Ok(response) = NostrRegistry::extract_response(&client, &event).await { - let public_key = response.public_key(); - let payload = response.payload(); - - // Decrypt the payload using the client signer - let decrypted = client_signer.nip44_decrypt(&public_key, payload).await?; - let secret = SecretKey::parse(&decrypted)?; - // Construct the encryption keys - let keys = Keys::new(secret); - - return Ok(keys); - } else { - log::error!("Failed to extract response from event"); - } - } - } - - Err(anyhow!("Failed to handle Encryption Key approval response")) - }) - } - - /// Set the client signer for the account - pub fn set_client(&mut self, signer: Arc, cx: &mut Context) { - self.client_signer.update(cx, |this, cx| { - *this = Some(signer); - cx.notify(); - }); - } - - /// Set the encryption signer for the account - pub fn set_encryption(&mut self, signer: Arc, cx: &mut Context) { - self.encryption.update(cx, |this, cx| { - *this = Some(signer); - cx.notify(); - }); - } - - /// Check if the account entity has an encryption key - pub fn has_encryption(&self, cx: &App) -> bool { - self.encryption.read(cx).is_some() - } - - /// Returns the encryption key - pub fn encryption_key(&self, cx: &App) -> Option> { - self.encryption.read(cx).clone() - } - - /// Returns the encryption announcement - pub fn announcement(&self) -> Option> { - self.announcement.clone() - } - - /// Returns the encryption requests - pub fn requests(&self) -> Entity> { - self.requests.clone() - } - - /// Push the encryption request - pub fn set_request(&mut self, request: Announcement, cx: &mut Context) { - self.requests.update(cx, |this, cx| { - this.insert(request); - cx.notify(); - }); - } - - /// Resubscribe to gift wrap events - fn resubscribe_messages(&self, cx: &App) -> Task<()> { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let gossip = nostr.read(cx).gossip(); - - let account = Account::global(cx); - let public_key = account.read(cx).public_key(); - - cx.background_spawn(async move { - let gossip = gossip.read().await; - let relays = gossip.messaging_relays(&public_key); - - NostrRegistry::get_messages(&client, public_key, &relays).await; - }) - } -} diff --git a/crates/encryption/src/signer.rs b/crates/encryption/src/signer.rs deleted file mode 100644 index 59513edf..00000000 --- a/crates/encryption/src/signer.rs +++ /dev/null @@ -1,9 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Deserialize, Serialize)] -pub enum SignerKind { - Encryption, - #[default] - User, - Auto, -} diff --git a/crates/encryption_ui/Cargo.toml b/crates/encryption_ui/Cargo.toml deleted file mode 100644 index 62ba28fe..00000000 --- a/crates/encryption_ui/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "encryption_ui" -version.workspace = true -edition.workspace = true -publish.workspace = true - -[dependencies] -state = { path = "../state" } -ui = { path = "../ui" } -theme = { path = "../theme" } -common = { path = "../common" } -account = { path = "../account" } -encryption = { path = "../encryption" } -person = { path = "../person" } -settings = { path = "../settings" } - -gpui.workspace = true - -nostr-sdk.workspace = true -anyhow.workspace = true -itertools.workspace = true -smallvec.workspace = true -smol.workspace = true -log.workspace = true -futures.workspace = true -serde.workspace = true -serde_json.workspace = true diff --git a/crates/encryption_ui/src/lib.rs b/crates/encryption_ui/src/lib.rs deleted file mode 100644 index 289ef604..00000000 --- a/crates/encryption_ui/src/lib.rs +++ /dev/null @@ -1,464 +0,0 @@ -use std::cell::Cell; -use std::rc::Rc; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::anyhow; -use common::shorten_pubkey; -use encryption::Encryption; -use futures::FutureExt; -use gpui::prelude::FluentBuilder; -use gpui::{ - div, px, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, - Styled, Subscription, Window, -}; -use smallvec::{smallvec, SmallVec}; -use state::Announcement; -use theme::ActiveTheme; -use ui::button::{Button, ButtonVariants}; -use ui::notification::Notification; -use ui::{h_flex, v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt}; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| EncryptionPanel::new(window, cx)) -} - -#[derive(Debug)] -pub struct EncryptionPanel { - /// Whether the panel is currently requesting encryption. - requesting: bool, - - /// Whether the panel is currently creating encryption. - creating: bool, - - /// Whether the panel is currently showing an error. - error: Entity>, - - /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, -} - -impl EncryptionPanel { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let error = cx.new(|_| None); - - let encryption = Encryption::global(cx); - let requests = encryption.read(cx).requests(); - - let mut subscriptions = smallvec![]; - - subscriptions.push( - // Observe encryption request - cx.observe_in(&requests, window, |this, state, window, cx| { - for req in state.read(cx).clone().into_iter() { - this.ask_for_approval(req, window, cx); - } - }), - ); - - Self { - requesting: false, - creating: false, - error, - _subscriptions: subscriptions, - } - } - - fn set_requesting(&mut self, status: bool, cx: &mut Context) { - self.requesting = status; - cx.notify(); - } - - fn set_creating(&mut self, status: bool, cx: &mut Context) { - self.creating = status; - cx.notify(); - } - - fn set_error(&mut self, error: impl Into, cx: &mut Context) { - self.error.update(cx, |this, cx| { - *this = Some(error.into()); - cx.notify(); - }); - } - - fn request(&mut self, window: &mut Window, cx: &mut Context) { - let encryption = Encryption::global(cx); - let send_request = encryption.read(cx).send_request(cx); - - // Ensure the user has not sent multiple requests - if self.requesting { - return; - } - self.set_requesting(true, cx); - - cx.spawn_in(window, async move |this, cx| { - match send_request.await { - Ok(Some(keys)) => { - this.update(cx, |this, cx| { - this.set_requesting(false, cx); - // Set the encryption key - encryption.update(cx, |this, cx| { - this.set_encryption(Arc::new(keys), cx); - }); - }) - .expect("Entity has been released"); - } - Ok(None) => { - this.update_in(cx, |this, window, cx| { - this.wait_for_approval(window, cx); - }) - .expect("Entity has been released"); - } - Err(e) => { - this.update(cx, |this, cx| { - this.set_requesting(false, cx); - this.set_error(e.to_string(), cx); - }) - .expect("Entity has been released"); - } - } - }) - .detach(); - } - - fn new_encryption(&mut self, window: &mut Window, cx: &mut Context) { - let encryption = Encryption::global(cx); - let reset = encryption.read(cx).new_encryption(cx); - - // Ensure the user has not sent multiple requests - if self.requesting { - return; - } - self.set_creating(true, cx); - - cx.spawn_in(window, async move |this, cx| { - match reset.await { - Ok(keys) => { - this.update(cx, |this, cx| { - this.set_creating(false, cx); - // Set the encryption key - encryption.update(cx, |this, cx| { - this.set_encryption(Arc::new(keys), cx); - this.listen_request(cx); - }); - }) - .expect("Entity has been released"); - } - Err(e) => { - this.update(cx, |this, cx| { - this.set_creating(false, cx); - this.set_error(e.to_string(), cx); - }) - .expect("Entity has been released"); - } - } - }) - .detach(); - } - - fn wait_for_approval(&mut self, window: &mut Window, cx: &mut Context) { - let encryption = Encryption::global(cx); - let wait_for_approval = encryption.read(cx).wait_for_approval(cx); - - cx.spawn_in(window, async move |this, cx| { - let timeout = cx.background_executor().timer(Duration::from_secs(30)); - - let result = futures::select! { - result = wait_for_approval.fuse() => { - // Ok(keys) - result - }, - _ = timeout.fuse() => { - Err(anyhow!("Timeout")) - } - }; - - this.update(cx, |this, cx| { - match result { - Ok(keys) => { - this.set_requesting(false, cx); - // Set the encryption key - encryption.update(cx, |this, cx| { - this.set_encryption(Arc::new(keys), cx); - }); - } - Err(e) => { - this.set_requesting(false, cx); - this.set_error(e.to_string(), cx); - } - }; - }) - .expect("Entity has been released"); - }) - .detach(); - } - - fn ask_for_approval(&mut self, req: Announcement, window: &mut Window, cx: &mut Context) { - let client_name = req.client_name(); - let target = req.public_key(); - let id = SharedString::from(req.id().to_hex()); - let loading = Rc::new(Cell::new(false)); - - let note = Notification::new() - .custom_id(id.clone()) - .autohide(false) - .icon(IconName::Encryption) - .title(SharedString::from("Encryption Key Request")) - .content(move |_window, cx| { - v_flex() - .gap_2() - .text_sm() - .child(SharedString::from( - "You've requested for the Encryption Key from:", - )) - .child( - v_flex() - .h_12() - .items_center() - .justify_center() - .px_2() - .rounded(cx.theme().radius) - .bg(cx.theme().warning_background) - .text_color(cx.theme().warning_foreground) - .child(client_name.clone()), - ) - .child( - h_flex() - .h_7() - .w_full() - .px_2() - .rounded(cx.theme().radius) - .bg(cx.theme().elevated_surface_background) - .child(SharedString::from(target.to_hex())), - ) - .into_any_element() - }) - .action(move |_window, _cx| { - Button::new("approve") - .label("Approve") - .small() - .primary() - .loading(loading.get()) - .disabled(loading.get()) - .on_click({ - let loading = Rc::clone(&loading); - let id = id.clone(); - - move |_ev, window, cx| { - // Set loading state to true - loading.set(true); - - let encryption = Encryption::global(cx); - let send_response = encryption.read(cx).send_response(target, cx); - let id = id.clone(); - - window - .spawn(cx, async move |cx| { - let result = send_response.await; - - cx.update(|window, cx| { - match result { - Ok(_) => { - window.clear_notification_by_id(id, cx); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } - }; - }) - .expect("Entity has been released"); - }) - .detach(); - } - }) - }); - - // Push the notification to the current window - window.push_notification(note, cx); - - // Focus the window if it's not active - if !window.is_window_hovered() { - window.activate_window(); - } - } -} - -impl Render for EncryptionPanel { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - const NOTICE: &str = "Found an Encryption Announcement"; - const SUGGEST: &str = "Please request the Encryption Key to continue using."; - - const DESCRIPTION: &str = "Encryption Key is used to replace the User's Identity in encryption and decryption messages. Coop will automatically fallback to User's Identity if needed."; - const WARNING: &str = "Encryption Key is still in the alpha stage. Please be cautious."; - - let encryption = Encryption::global(cx); - let announcement = encryption.read(cx).announcement(); - let has_encryption = encryption.read(cx).has_encryption(cx); - - v_flex() - .p_2() - .max_w(px(340.)) - .w(px(340.)) - .text_sm() - .when_some(announcement.as_ref(), |this, announcement| { - let pubkey = shorten_pubkey(announcement.public_key(), 16); - let client_name = announcement.client_name(); - - this.child( - v_flex() - .gap_2() - .when(has_encryption, |this| { - this.child( - h_flex() - .gap_1p5() - .text_sm() - .font_semibold() - .child( - Icon::new(IconName::CheckCircle) - .text_color(cx.theme().element_foreground) - .small(), - ) - .child(SharedString::from("Encryption Key has been set")), - ) - }) - .when(!has_encryption, |this| { - this.child(div().font_semibold().child(SharedString::from(NOTICE))) - }) - .child( - v_flex() - .gap_1() - .child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Client Name:")), - ) - .child( - h_flex() - .h_12() - .items_center() - .justify_center() - .rounded(cx.theme().radius) - .bg(cx.theme().elevated_surface_background) - .child(client_name.clone()), - ), - ) - .child( - v_flex() - .gap_1() - .child( - div() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Client Public Key:")), - ) - .child( - h_flex() - .h_7() - .w_full() - .px_2() - .rounded(cx.theme().radius) - .bg(cx.theme().elevated_surface_background) - .child(SharedString::from(pubkey)), - ), - ) - .when(!has_encryption, |this| { - this.child( - v_flex() - .gap_2() - .child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from(SUGGEST)), - ) - .child( - h_flex() - .mt_2() - .gap_1() - .when(!self.requesting, |this| { - this.child( - Button::new("reset") - .label("Reset") - .flex_1() - .small() - .ghost_alt() - .loading(self.creating) - .disabled(self.creating) - .on_click(cx.listener( - move |this, _ev, window, cx| { - this.new_encryption(window, cx); - }, - )), - ) - }) - .when(!self.creating, |this| { - this.child( - Button::new("request") - .label({ - if self.requesting { - "Wait for approval" - } else { - "Request" - } - }) - .flex_1() - .small() - .primary() - .loading(self.requesting) - .disabled(self.requesting) - .on_click(cx.listener( - move |this, _ev, window, cx| { - this.request(window, cx); - }, - )), - ) - }), - ), - ) - }), - ) - }) - .when_none(&announcement, |this| { - this.child( - v_flex() - .gap_2() - .child( - div() - .font_semibold() - .child(SharedString::from("Set up Encryption Key")), - ) - .child(SharedString::from(DESCRIPTION)) - .child( - div() - .text_xs() - .text_color(cx.theme().warning_foreground) - .child(SharedString::from(WARNING)), - ) - .child( - Button::new("create") - .label("Setup") - .flex_1() - .small() - .primary() - .loading(self.creating) - .disabled(self.creating) - .on_click(cx.listener(move |this, _ev, window, cx| { - this.new_encryption(window, cx); - })), - ), - ) - }) - .when_some(self.error.read(cx).as_ref(), |this, error| { - this.child( - div() - .text_xs() - .text_center() - .text_color(cx.theme().danger_foreground) - .child(error.clone()), - ) - }) - } -} diff --git a/crates/relay_auth/Cargo.toml b/crates/relay_auth/Cargo.toml index 1d96c012..1b88afd4 100644 --- a/crates/relay_auth/Cargo.toml +++ b/crates/relay_auth/Cargo.toml @@ -17,4 +17,5 @@ nostr-sdk.workspace = true anyhow.workspace = true smallvec.workspace = true smol.workspace = true +flume.workspace = true log.workspace = true diff --git a/crates/relay_auth/src/lib.rs b/crates/relay_auth/src/lib.rs index 221bb2b2..e63f361c 100644 --- a/crates/relay_auth/src/lib.rs +++ b/crates/relay_auth/src/lib.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; use std::cell::Cell; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::hash::{Hash, Hasher}; use std::rc::Rc; @@ -12,7 +12,7 @@ use gpui::{ use nostr_sdk::prelude::*; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; -use state::NostrRegistry; +use state::{tracker, NostrRegistry}; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::notification::Notification; @@ -25,10 +25,7 @@ pub fn init(window: &mut Window, cx: &mut App) { RelayAuth::set_global(cx.new(|cx| RelayAuth::new(window, cx)), cx); } -struct GlobalRelayAuth(Entity); - -impl Global for GlobalRelayAuth {} - +/// Authentication request #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct AuthRequest { pub url: RelayUrl, @@ -50,6 +47,11 @@ impl AuthRequest { } } +struct GlobalRelayAuth(Entity); + +impl Global for GlobalRelayAuth {} + +// Relay authentication #[derive(Debug)] pub struct RelayAuth { /// Entity for managing auth requests @@ -77,8 +79,13 @@ impl RelayAuth { fn new(window: &mut Window, cx: &mut Context) -> Self { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); + + // Get the current entity let entity = cx.entity(); + // Channel for communication between nostr and gpui + let (tx, rx) = flume::bounded::(100); + let mut subscriptions = smallvec![]; let mut tasks = smallvec![]; @@ -103,26 +110,20 @@ impl RelayAuth { ); tasks.push( - // Handle notifications + // Handle nostr notifications + cx.background_spawn(async move { + Self::handle_notifications(&client, &tx).await; + }), + ); + + tasks.push( + // Update GPUI states cx.spawn(async move |this, cx| { - let mut notifications = client.notifications(); - let mut challenges: HashSet> = HashSet::new(); - - while let Ok(notification) = notifications.recv().await { - let RelayPoolNotification::Message { message, relay_url } = notification else { - // Skip if the notification is not a message - continue; - }; - - if let RelayMessage::Auth { challenge } = message { - if challenges.insert(challenge.clone()) { - this.update(cx, |this, cx| { - this.requests.insert(AuthRequest::new(challenge, relay_url)); - cx.notify(); - }) - .expect("Entity has been released"); - }; - } + while let Ok(request) = rx.recv_async().await { + this.update(cx, |this, cx| { + this.add_request(request, cx); + }) + .ok(); } }), ); @@ -134,6 +135,31 @@ impl RelayAuth { } } + // Handle nostr notifications + async fn handle_notifications(client: &Client, tx: &flume::Sender) { + let mut notifications = client.notifications(); + let mut processed_challenges = HashSet::new(); + + while let Ok(notification) = notifications.recv().await { + if let RelayPoolNotification::Message { + message: RelayMessage::Auth { challenge }, + relay_url, + } = notification + { + if processed_challenges.insert(challenge.clone()) { + let request = AuthRequest::new(challenge, relay_url); + tx.send_async(request).await.ok(); + }; + } + } + } + + /// Add a new authentication request. + fn add_request(&mut self, request: AuthRequest, cx: &mut Context) { + self.requests.insert(request); + cx.notify(); + } + /// Get the number of pending requests. pub fn pending_requests(&self, _cx: &App) -> usize { self.requests.len() @@ -152,7 +178,6 @@ impl RelayAuth { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let tracker = nostr.read(cx).tracker(); let challenge = req.challenge.to_owned(); let url = req.url.to_owned(); @@ -190,30 +215,14 @@ impl RelayAuth { // Re-subscribe to previous subscription relay.resubscribe().await?; - // Get all failed events that need to be resent - let mut tracker = tracker.write().await; - - let ids: Vec = tracker - .resend_queue - .iter() - .filter(|(_, url)| relay_url == *url) - .map(|(id, _)| *id) - .collect(); + // Get all pending events that need to be resent + let mut tracker = tracker().write().await; + let ids: Vec = tracker.pending_resend(relay_url); for id in ids.into_iter() { - if let Some(relay_url) = tracker.resend_queue.remove(&id) { - if let Some(event) = client.database().event_by_id(&id).await? { - let event_id = relay.send_event(&event).await?; - - let output = Output { - val: event_id, - failed: HashMap::new(), - success: HashSet::from([relay_url]), - }; - - tracker.sent_ids.insert(event_id); - tracker.resent_ids.push(output); - } + if let Some(event) = client.database().event_by_id(&id).await? { + let event_id = relay.send_event(&event).await?; + tracker.sent(event_id); } } diff --git a/crates/state/Cargo.toml b/crates/state/Cargo.toml index 9abb248a..51ac44e4 100644 --- a/crates/state/Cargo.toml +++ b/crates/state/Cargo.toml @@ -12,10 +12,8 @@ nostr-lmdb.workspace = true gpui.workspace = true smol.workspace = true -smallvec.workspace = true +flume.workspace = true log.workspace = true anyhow.workspace = true -serde.workspace = true -serde_json.workspace = true -rustls = "0.23.23" +rustls = "0.23" diff --git a/crates/state/src/event.rs b/crates/state/src/event.rs new file mode 100644 index 00000000..e7de9364 --- /dev/null +++ b/crates/state/src/event.rs @@ -0,0 +1,46 @@ +use std::collections::HashSet; +use std::sync::{Arc, OnceLock}; + +use nostr_sdk::prelude::*; +use smol::lock::RwLock; + +static TRACKER: OnceLock>> = OnceLock::new(); + +pub fn tracker() -> &'static Arc> { + TRACKER.get_or_init(|| Arc::new(RwLock::new(EventTracker::default()))) +} + +/// Event tracker +#[derive(Debug, Clone, Default)] +pub struct EventTracker { + /// Tracking events sent by Coop in the current session + sent_ids: HashSet, + + /// Events that need to be resent later + pending_resend: HashSet<(EventId, RelayUrl)>, +} + +impl EventTracker { + /// Check if an event was sent by Coop in the current session. + pub fn is_sent_by_coop(&self, id: &EventId) -> bool { + self.sent_ids.contains(id) + } + + /// Mark an event as sent by Coop. + pub fn sent(&mut self, id: EventId) { + self.sent_ids.insert(id); + } + + /// Get all events that need to be resent later for a specific relay. + pub fn pending_resend(&mut self, relay: &RelayUrl) -> Vec { + self.pending_resend + .extract_if(|(_id, url)| url == relay) + .map(|(id, _url)| id) + .collect() + } + + /// Add an event (id and relay url) to the pending resend set. + pub fn add_to_pending(&mut self, id: EventId, url: RelayUrl) { + self.pending_resend.insert((id, url)); + } +} diff --git a/crates/state/src/gossip.rs b/crates/state/src/gossip.rs new file mode 100644 index 00000000..48440aa3 --- /dev/null +++ b/crates/state/src/gossip.rs @@ -0,0 +1,103 @@ +use std::collections::{HashMap, HashSet}; + +use nostr_sdk::prelude::*; + +/// Gossip +#[derive(Debug, Clone, Default)] +pub struct Gossip { + /// Gossip relays for each public key + relays: HashMap)>>, + /// Messaging relays for each public key + messaging_relays: HashMap>, +} + +impl Gossip { + /// Get read relays for a given public key + pub fn read_relays(&self, public_key: &PublicKey) -> Vec { + self.relays + .get(public_key) + .map(|relays| { + relays + .iter() + .filter_map(|(url, metadata)| { + if metadata.is_none() || metadata == &Some(RelayMetadata::Read) { + Some(url.to_owned()) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default() + } + + /// Get write relays for a given public key + pub fn write_relays(&self, public_key: &PublicKey) -> Vec { + self.relays + .get(public_key) + .map(|relays| { + relays + .iter() + .filter_map(|(url, metadata)| { + if metadata.is_none() || metadata == &Some(RelayMetadata::Write) { + Some(url.to_owned()) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default() + } + + /// Insert gossip relays for a public key + pub fn insert_relays(&mut self, event: &Event) { + self.relays.entry(event.pubkey).or_default().extend( + event + .tags + .iter() + .filter_map(|tag| { + if let Some(TagStandard::RelayMetadata { + relay_url, + metadata, + }) = tag.clone().to_standardized() + { + Some((relay_url, metadata)) + } else { + None + } + }) + .take(3), + ); + } + + /// Get messaging relays for a given public key + pub fn messaging_relays(&self, public_key: &PublicKey) -> Vec { + self.messaging_relays + .get(public_key) + .cloned() + .unwrap_or_default() + .into_iter() + .collect() + } + + /// Insert messaging relays for a public key + pub fn insert_messaging_relays(&mut self, event: &Event) { + self.messaging_relays + .entry(event.pubkey) + .or_default() + .extend( + event + .tags + .iter() + .filter_map(|tag| { + if let Some(TagStandard::Relay(url)) = tag.as_standardized() { + Some(url.to_owned()) + } else { + None + } + }) + .take(3), + ); + } +} diff --git a/crates/state/src/identity.rs b/crates/state/src/identity.rs new file mode 100644 index 00000000..2d296b4e --- /dev/null +++ b/crates/state/src/identity.rs @@ -0,0 +1,83 @@ +use nostr_sdk::prelude::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RelayState { + #[default] + Initial, + NotSet, + Set, +} + +impl RelayState { + pub fn is_initial(&self) -> bool { + matches!(self, RelayState::Initial) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Identity { + /// The public key of the account + public_key: Option, + + /// Status of the current user NIP-65 relays + relay_list: RelayState, + + /// Status of the current user NIP-17 relays + messaging_relays: RelayState, +} + +impl AsRef for Identity { + fn as_ref(&self) -> &Identity { + self + } +} + +impl Identity { + pub fn new() -> Self { + Self { + public_key: None, + relay_list: RelayState::default(), + messaging_relays: RelayState::default(), + } + } + + /// Sets the state of the NIP-65 relays. + pub fn set_relay_list_state(&mut self, state: RelayState) { + self.relay_list = state; + } + + /// Returns the state of the NIP-65 relays. + pub fn relay_list_state(&self) -> RelayState { + self.relay_list + } + + pub fn set_messaging_relays_state(&mut self, state: RelayState) { + self.messaging_relays = state; + } + + /// Returns the state of the NIP-17 relays. + pub fn messaging_relays_state(&self) -> RelayState { + self.messaging_relays + } + + /// Returns true if the identity has a public key. + pub fn has_public_key(&self) -> bool { + self.public_key.is_some() + } + + /// Sets the public key of the identity. + pub fn set_public_key(&mut self, public_key: PublicKey) { + self.public_key = Some(public_key); + } + + /// Unsets the public key of the identity. + pub fn unset_public_key(&mut self) { + self.public_key = None; + } + + /// Returns the public key of the identity. + pub fn public_key(&self) -> PublicKey { + // This method is safe to unwrap because the public key is always called when the identity is created. + self.public_key.unwrap() + } +} diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index de10ecd5..67201b88 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -1,26 +1,32 @@ use std::collections::HashSet; -use std::sync::Arc; use std::time::Duration; -use anyhow::{anyhow, Context as AnyhowContext, Error}; +use anyhow::Error; use common::{config_dir, BOOTSTRAP_RELAYS, SEARCH_RELAYS}; -use gpui::{App, AppContext, Context, Entity, Global, Task}; +use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; use nostr_lmdb::NostrLmdb; use nostr_sdk::prelude::*; -use smallvec::{smallvec, SmallVec}; -use smol::lock::RwLock; -pub use storage::*; -pub use tracker::*; -mod storage; -mod tracker; +mod event; +mod gossip; +mod identity; -pub const GIFTWRAP_SUBSCRIPTION: &str = "gift-wrap-events"; +pub use event::*; +pub use gossip::*; +pub use identity::*; + +use crate::identity::Identity; pub fn init(cx: &mut App) { NostrRegistry::set_global(cx.new(NostrRegistry::new), cx); } +/// Default timeout for subscription +pub const TIMEOUT: u64 = 3; + +/// Default subscription id for gift wrap events +pub const GIFTWRAP_SUBSCRIPTION: &str = "giftwrap-events"; + struct GlobalNostrRegistry(Entity); impl Global for GlobalNostrRegistry {} @@ -28,17 +34,27 @@ impl Global for GlobalNostrRegistry {} /// Nostr Registry #[derive(Debug)] pub struct NostrRegistry { - /// Nostr Client + /// Nostr client client: Client, - /// Custom gossip implementation - gossip: Arc>, + /// App keys + /// + /// Used for Nostr Connect and NIP-4e operations + app_keys: Keys, - /// Tracks activity related to Nostr events - tracker: Arc>, + /// Current identity (user's public key) + /// + /// Set by the current Nostr signer + identity: Entity, + + /// Gossip implementation + gossip: Entity, /// Tasks for asynchronous operations - _tasks: SmallVec<[Task<()>; 1]>, + tasks: Vec>>, + + /// Subscriptions + _subscriptions: Vec, } impl NostrRegistry { @@ -79,310 +95,424 @@ impl NostrRegistry { // Construct the nostr client let client = ClientBuilder::default().database(lmdb).opts(opts).build(); + let _ = tracker(); - let tracker = Arc::new(RwLock::new(EventTracker::default())); - let gossip = Arc::new(RwLock::new(Gossip::default())); + // Get the app keys + let app_keys = Self::create_or_init_app_keys().unwrap(); - let mut tasks = smallvec![]; + // Construct the gossip entity + let gossip = cx.new(|_| Gossip::default()); + let async_gossip = gossip.downgrade(); + + // Construct the identity entity + let identity = cx.new(|_| Identity::default()); + + // Channel for communication between nostr and gpui + let (tx, rx) = flume::bounded::(2048); + + let mut subscriptions = vec![]; + let mut tasks = vec![]; + + subscriptions.push( + // Observe the identity entity + cx.observe(&identity, |this, state, cx| { + let identity = state.read(cx); + + if identity.has_public_key() { + match identity.relay_list_state() { + RelayState::Initial => { + this.get_relay_list(cx); + } + RelayState::Set => match identity.messaging_relays_state() { + RelayState::Initial => { + this.get_messaging_relays(cx); + } + RelayState::Set => { + this.get_messages(cx); + } + _ => {} + }, + _ => {} + } + } + }), + ); tasks.push( // Establish connection to the bootstrap relays - // - // And handle notifications from the nostr relay pool channel cx.background_spawn({ let client = client.clone(); - let gossip = Arc::clone(&gossip); - let tracker = Arc::clone(&tracker); - let _ = initialized_at(); async move { - // Connect to the bootstrap relays - Self::connect(&client).await; + // Add bootstrap relay to the relay pool + for url in BOOTSTRAP_RELAYS.into_iter() { + client.add_relay(url).await?; + } - // Handle notifications from the relay pool - Self::handle_notifications(&client, &gossip, &tracker).await; + // Add search relay to the relay pool + for url in SEARCH_RELAYS.into_iter() { + client.add_relay(url).await?; + } + + // Connect to all added relays + client.connect().await; + + Ok(()) } }), ); + tasks.push( + // Handle nostr notifications + cx.background_spawn({ + let client = client.clone(); + + async move { Self::handle_notifications(&client, &tx).await } + }), + ); + + tasks.push( + // Update GPUI states + cx.spawn(async move |_this, cx| { + while let Ok(event) = rx.recv_async().await { + match event.kind { + Kind::RelayList => { + async_gossip.update(cx, |this, cx| { + this.insert_relays(&event); + cx.notify(); + })?; + } + Kind::InboxRelays => { + async_gossip.update(cx, |this, cx| { + this.insert_messaging_relays(&event); + cx.notify(); + })?; + } + _ => {} + } + } + + Ok(()) + }), + ); + Self { client, - tracker, + identity, gossip, - _tasks: tasks, + app_keys, + _subscriptions: subscriptions, + tasks, } } - /// Establish connection to the bootstrap relays - async fn connect(client: &Client) { - // Get all bootstrapping relays - let mut urls = vec![]; - urls.extend(BOOTSTRAP_RELAYS); - urls.extend(SEARCH_RELAYS); - - // Add relay to the relay pool - for url in urls.into_iter() { - client.add_relay(url).await.ok(); - } - - // Connect to all added relays - client.connect().await; - } - - async fn handle_notifications( - client: &Client, - gossip: &Arc>, - tracker: &Arc>, - ) { + // Handle nostr notifications + async fn handle_notifications(client: &Client, tx: &flume::Sender) -> Result<(), Error> { let mut notifications = client.notifications(); let mut processed_events = HashSet::new(); while let Ok(notification) = notifications.recv().await { - let RelayPoolNotification::Message { message, relay_url } = notification else { - // Skip if the notification is not a message - continue; - }; + if let RelayPoolNotification::Message { message, relay_url } = notification { + match message { + RelayMessage::Event { event, .. } => { + if !processed_events.insert(event.id) { + // Skip if the event has already been processed + continue; + } - match message { - RelayMessage::Event { event, .. } => { - if !processed_events.insert(event.id) { - // Skip if the event has already been processed - continue; + match event.kind { + Kind::RelayList => { + tx.send_async(event.into_owned()).await?; + } + Kind::InboxRelays => { + tx.send_async(event.into_owned()).await?; + } + _ => {} + } } + RelayMessage::Ok { + event_id, message, .. + } => { + let msg = MachineReadablePrefix::parse(&message); + let mut tracker = tracker().write().await; - match event.kind { - Kind::RelayList => { - let mut gossip = gossip.write().await; - gossip.insert_relays(&event); - - let urls: Vec = Self::extract_write_relays(&event); - let author = event.pubkey; - - log::info!("Write relays: {urls:?}"); - - // Fetch user's encryption announcement event - Self::get(client, &urls, author, Kind::Custom(10044)).await; - // Fetch user's messaging relays event - Self::get(client, &urls, author, Kind::InboxRelays).await; - - // Verify if the event is belonging to the current user - if Self::is_self_authored(client, &event).await { - // Fetch user's metadata event - Self::get(client, &urls, author, Kind::Metadata).await; - // Fetch user's contact list event - Self::get(client, &urls, author, Kind::ContactList).await; - } + // Handle authentication messages + if let Some(MachineReadablePrefix::AuthRequired) = msg { + // Keep track of events that need to be resent after authentication + tracker.add_to_pending(event_id, relay_url); + } else { + // Keep track of events sent by Coop + tracker.sent(event_id) } - Kind::InboxRelays => { - let mut gossip = gossip.write().await; - gossip.insert_messaging_relays(&event); - - if Self::is_self_authored(client, &event).await { - // Extract user's messaging relays - let urls: Vec = - nip17::extract_relay_list(&event).cloned().collect(); - - // Fetch user's inbox messages in the extracted relays - Self::get_messages(client, event.pubkey, &urls).await; - } - } - Kind::Custom(10044) => { - let mut gossip = gossip.write().await; - gossip.insert_announcement(&event); - } - Kind::ContactList => { - if Self::is_self_authored(client, &event).await { - let public_keys: Vec = - event.tags.public_keys().copied().collect(); - - if let Err(e) = - Self::get_metadata_for_list(client, public_keys).await - { - log::error!("Failed to get metadata for list: {e}"); - } - } - } - _ => {} - }; - } - RelayMessage::Ok { - event_id, message, .. - } => { - let msg = MachineReadablePrefix::parse(&message); - let mut tracker = tracker.write().await; - - // Message that need to be authenticated will be handled separately - if let Some(MachineReadablePrefix::AuthRequired) = msg { - // Keep track of events that need to be resent after authentication - tracker.resend_queue.insert(event_id, relay_url); - } else { - // Keep track of events sent by Coop - tracker.sent_ids.insert(event_id); } + _ => {} } - _ => {} } } - } - - /// Check if event is published by current user - pub async fn is_self_authored(client: &Client, event: &Event) -> bool { - if let Ok(signer) = client.signer().await { - if let Ok(public_key) = signer.get_public_key().await { - return public_key == event.pubkey; - } - } - false - } - - /// Get event that match the given kind for a given author - async fn get(client: &Client, urls: &[RelayUrl], author: PublicKey, kind: Kind) { - // Skip if no relays are provided - if urls.is_empty() { - return; - } - - // Ensure relay connections - for url in urls.iter() { - client.add_relay(url).await.ok(); - client.connect_relay(url).await.ok(); - } - - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let filter = Filter::new().author(author).kind(kind).limit(1); - - // Subscribe to filters from the user's write relays - if let Err(e) = client.subscribe_to(urls, filter, Some(opts)).await { - log::error!("Failed to subscribe: {}", e); - } - } - - /// Get all gift wrap events in the messaging relays for a given public key - pub async fn get_messages(client: &Client, public_key: PublicKey, urls: &[RelayUrl]) { - // Verify that there are relays provided - if urls.is_empty() { - return; - } - - // Ensure relay connection - for url in urls.iter() { - client.add_relay(url).await.ok(); - client.connect_relay(url).await.ok(); - } - - let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); - let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); - - // Unsubscribe from the previous subscription - client.unsubscribe(&id).await; - - // Subscribe to filters to user's messaging relays - if let Err(e) = client.subscribe_with_id_to(urls, id, filter, None).await { - log::error!("Failed to subscribe: {}", e); - } else { - log::info!("Subscribed to gift wrap events for public key {public_key}",); - } - } - - /// Get metadata for a list of public keys - async fn get_metadata_for_list(client: &Client, pubkeys: Vec) -> Result<(), Error> { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let kinds = vec![Kind::Metadata, Kind::ContactList]; - - // Return if the list is empty - if pubkeys.is_empty() { - return Err(anyhow!("You need at least one public key".to_string(),)); - } - - let filter = Filter::new() - .limit(pubkeys.len() * kinds.len()) - .authors(pubkeys) - .kinds(kinds); - - // Subscribe to filters to the bootstrap relays - client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await?; Ok(()) } - pub fn extract_read_relays(event: &Event) -> Vec { - nip65::extract_relay_list(event) - .filter_map(|(url, metadata)| { - if metadata.is_none() || metadata == &Some(RelayMetadata::Read) { - Some(url.to_owned()) - } else { - None - } - }) - .take(3) - .collect() + /// Get or create a new app keys + fn create_or_init_app_keys() -> Result { + let dir = config_dir().join(".app_keys"); + let content = match std::fs::read(&dir) { + Ok(content) => content, + Err(_) => { + // Generate new keys if file doesn't exist + let keys = Keys::generate(); + let secret_key = keys.secret_key(); + + std::fs::create_dir_all(dir.parent().unwrap())?; + std::fs::write(&dir, secret_key.to_secret_bytes())?; + + return Ok(keys); + } + }; + let secret_key = SecretKey::from_slice(&content)?; + let keys = Keys::new(secret_key); + + Ok(keys) } - pub fn extract_write_relays(event: &Event) -> Vec { - nip65::extract_relay_list(event) - .filter_map(|(url, metadata)| { - if metadata.is_none() || metadata == &Some(RelayMetadata::Write) { - Some(url.to_owned()) - } else { - None - } - }) - .take(3) - .collect() - } - - /// Extract an encryption keys announcement from an event. - pub fn extract_announcement(event: &Event) -> Result { - let public_key = event - .tags - .iter() - .find(|tag| tag.kind().as_str() == "n" || tag.kind().as_str() == "pubkey") - .and_then(|tag| tag.content()) - .and_then(|c| PublicKey::parse(c).ok()) - .context("Cannot parse public key from the event's tags")?; - - let client_name = event - .tags - .find(TagKind::Client) - .and_then(|tag| tag.content()) - .map(|c| c.to_string()); - - Ok(Announcement::new(event.id, client_name, public_key)) - } - - /// Extract an encryption keys response from an event. - pub async fn extract_response(client: &Client, event: &Event) -> Result { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - if event.pubkey != public_key { - return Err(anyhow!("Event does not belong to current user")); - } - - let client_pubkey = event - .tags - .find(TagKind::custom("P")) - .and_then(|tag| tag.content()) - .and_then(|c| PublicKey::parse(c).ok()) - .context("Cannot parse public key from the event's tags")?; - - Ok(Response::new(event.content.clone(), client_pubkey)) - } - - /// Returns a reference to the nostr client. + /// Get the nostr client pub fn client(&self) -> Client { self.client.clone() } - /// Returns a reference to the event tracker. - pub fn tracker(&self) -> Arc> { - Arc::clone(&self.tracker) + /// Get the app keys + pub fn app_keys(&self) -> &Keys { + &self.app_keys } - /// Returns a reference to the cache manager. - pub fn gossip(&self) -> Arc> { - Arc::clone(&self.gossip) + /// Get current identity + pub fn identity(&self, cx: &App) -> Identity { + self.identity.read(cx).clone() + } + + /// Get a relay hint (messaging relay) for a given public key + pub fn relay_hint(&self, public_key: &PublicKey, cx: &App) -> Option { + self.gossip + .read(cx) + .messaging_relays(public_key) + .first() + .cloned() + } + + /// Get a list of messaging relays for a given public key + pub fn messaging_relays(&self, public_key: &PublicKey, cx: &App) -> Vec { + self.gossip.read(cx).messaging_relays(public_key) + } + + /// Set the signer for the nostr client and verify the public key + pub fn set_signer(&mut self, signer: T, cx: &mut Context) + where + T: NostrSigner + 'static, + { + let client = self.client(); + let identity = self.identity.downgrade(); + + // Create a task to update the signer and verify the public key + let task: Task> = cx.background_spawn(async move { + // Update signer + client.set_signer(signer).await; + + // Verify signer + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + + Ok(public_key) + }); + + self.tasks.push(cx.spawn(async move |_this, cx| { + match task.await { + Ok(public_key) => { + identity.update(cx, |this, cx| { + this.set_public_key(public_key); + cx.notify(); + })?; + } + Err(e) => { + log::error!("Failed to set signer: {e}"); + } + }; + + Ok(()) + })); + } + + /// Unset the current signer + pub fn unset_signer(&mut self, cx: &mut Context) { + let client = self.client(); + let async_identity = self.identity.downgrade(); + + self.tasks.push(cx.spawn(async move |_this, cx| { + // Unset the signer from nostr client + cx.background_executor() + .await_on_background(async move { + client.unset_signer().await; + }) + .await; + + // Unset the current identity + async_identity + .update(cx, |this, cx| { + this.unset_public_key(); + cx.notify(); + }) + .ok(); + + Ok(()) + })); + } + + // Get relay list for current user + fn get_relay_list(&mut self, cx: &mut Context) { + let client = self.client(); + let async_identity = self.identity.downgrade(); + let public_key = self.identity(cx).public_key(); + + let task: Task> = cx.background_spawn(async move { + let filter = Filter::new() + .kind(Kind::RelayList) + .author(public_key) + .limit(1); + + let mut stream = client + .stream_events_from(BOOTSTRAP_RELAYS, vec![filter], Duration::from_secs(TIMEOUT)) + .await?; + + while let Some((_url, res)) = stream.next().await { + if let Ok(event) = res { + log::info!("Received relay list event: {event:?}"); + return Ok(RelayState::Set); + } + } + + Ok(RelayState::NotSet) + }); + + self.tasks.push(cx.spawn(async move |_this, cx| { + match task.await { + Ok(state) => { + async_identity + .update(cx, |this, cx| { + this.set_relay_list_state(state); + cx.notify(); + }) + .ok(); + } + Err(e) => { + log::error!("Failed to get relay list: {e}"); + } + } + + Ok(()) + })); + } + + /// Get messaging relays for current user + fn get_messaging_relays(&mut self, cx: &mut Context) { + let client = self.client(); + let async_identity = self.identity.downgrade(); + let public_key = self.identity(cx).public_key(); + let write_relays = self.gossip.read(cx).write_relays(&public_key); + + let task: Task> = cx.background_spawn(async move { + let filter = Filter::new() + .kind(Kind::InboxRelays) + .author(public_key) + .limit(1); + + let mut stream = client + .stream_events_from(write_relays, vec![filter], Duration::from_secs(TIMEOUT)) + .await?; + + while let Some((_url, res)) = stream.next().await { + if let Ok(event) = res { + log::info!("Received messaging relays event: {event:?}"); + return Ok(RelayState::Set); + } + } + + Ok(RelayState::NotSet) + }); + + self.tasks.push(cx.spawn(async move |_this, cx| { + match task.await { + Ok(state) => { + async_identity + .update(cx, |this, cx| { + this.set_messaging_relays_state(state); + cx.notify(); + }) + .ok(); + } + Err(e) => { + log::error!("Failed to get messaging relays: {e}"); + } + } + + Ok(()) + })); + } + + /// Continuously get gift wrap events for the current user in their messaging relays + fn get_messages(&mut self, cx: &mut Context) { + let client = self.client(); + let public_key = self.identity(cx).public_key(); + let messaging_relays = self.gossip.read(cx).messaging_relays(&public_key); + + cx.background_spawn(async move { + let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); + let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + + if let Err(e) = client + .subscribe_with_id_to(messaging_relays, id, vec![filter], None) + .await + { + log::error!("Failed to subscribe to gift wrap events: {e}"); + } + }) + .detach(); + } + + /// Publish an event to author's write relays + pub fn publish(&self, event: Event, cx: &App) -> Task, Error>> { + let client = self.client(); + let write_relays = self.gossip.read(cx).write_relays(&event.pubkey); + + cx.background_spawn(async move { Ok(client.send_event_to(&write_relays, &event).await?) }) + } + + /// Subscribe to event kinds to author's write relays + pub fn subscribe(&self, kinds: I, author: PublicKey, cx: &App) + where + I: Into>, + { + let client = self.client(); + let write_relays = self.gossip.read(cx).write_relays(&author); + + // Construct filters based on event kinds + let filters: Vec = kinds + .into() + .into_iter() + .map(|kind| Filter::new().kind(kind).author(author).limit(1)) + .collect(); + + // Construct subscription options + let opts = SubscribeAutoCloseOptions::default() + .timeout(Some(Duration::from_secs(TIMEOUT))) + .exit_policy(ReqExitPolicy::ExitOnEOSE); + + cx.background_spawn(async move { + if let Err(e) = client + .subscribe_to(&write_relays, filters, Some(opts)) + .await + { + log::error!("Failed to create a subscription: {e}"); + }; + }) + .detach(); } } diff --git a/crates/encryption/Cargo.toml b/crates/state_old/Cargo.toml similarity index 71% rename from crates/encryption/Cargo.toml rename to crates/state_old/Cargo.toml index 9c64c660..5f668cd9 100644 --- a/crates/encryption/Cargo.toml +++ b/crates/state_old/Cargo.toml @@ -1,22 +1,21 @@ [package] -name = "encryption" +name = "state_old" version.workspace = true edition.workspace = true publish.workspace = true [dependencies] -state = { path = "../state" } common = { path = "../common" } -account = { path = "../account" } + +nostr-sdk.workspace = true +nostr-lmdb.workspace = true gpui.workspace = true -nostr-sdk.workspace = true - -anyhow.workspace = true -smallvec.workspace = true smol.workspace = true -futures.workspace = true -flume.workspace = true +smallvec.workspace = true log.workspace = true +anyhow.workspace = true serde.workspace = true serde_json.workspace = true + +rustls = "0.23.23" diff --git a/crates/state_old/src/lib.rs b/crates/state_old/src/lib.rs new file mode 100644 index 00000000..de10ecd5 --- /dev/null +++ b/crates/state_old/src/lib.rs @@ -0,0 +1,388 @@ +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, Context as AnyhowContext, Error}; +use common::{config_dir, BOOTSTRAP_RELAYS, SEARCH_RELAYS}; +use gpui::{App, AppContext, Context, Entity, Global, Task}; +use nostr_lmdb::NostrLmdb; +use nostr_sdk::prelude::*; +use smallvec::{smallvec, SmallVec}; +use smol::lock::RwLock; +pub use storage::*; +pub use tracker::*; + +mod storage; +mod tracker; + +pub const GIFTWRAP_SUBSCRIPTION: &str = "gift-wrap-events"; + +pub fn init(cx: &mut App) { + NostrRegistry::set_global(cx.new(NostrRegistry::new), cx); +} + +struct GlobalNostrRegistry(Entity); + +impl Global for GlobalNostrRegistry {} + +/// Nostr Registry +#[derive(Debug)] +pub struct NostrRegistry { + /// Nostr Client + client: Client, + + /// Custom gossip implementation + gossip: Arc>, + + /// Tracks activity related to Nostr events + tracker: Arc>, + + /// Tasks for asynchronous operations + _tasks: SmallVec<[Task<()>; 1]>, +} + +impl NostrRegistry { + /// Retrieve the global nostr state + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + /// Set the global nostr instance + fn set_global(state: Entity, cx: &mut App) { + cx.set_global(GlobalNostrRegistry(state)); + } + + /// Create a new nostr instance + fn new(cx: &mut Context) -> Self { + // rustls uses the `aws_lc_rs` provider by default + // This only errors if the default provider has already + // been installed. We can ignore this `Result`. + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .ok(); + + // Construct the nostr client options + let opts = ClientOptions::new() + .automatic_authentication(false) + .verify_subscriptions(false) + .sleep_when_idle(SleepWhenIdle::Enabled { + timeout: Duration::from_secs(600), + }); + + // Construct the lmdb + let lmdb = cx.background_executor().block(async move { + let path = config_dir().join("nostr"); + NostrLmdb::open(path) + .await + .expect("Failed to initialize database") + }); + + // Construct the nostr client + let client = ClientBuilder::default().database(lmdb).opts(opts).build(); + + let tracker = Arc::new(RwLock::new(EventTracker::default())); + let gossip = Arc::new(RwLock::new(Gossip::default())); + + let mut tasks = smallvec![]; + + tasks.push( + // Establish connection to the bootstrap relays + // + // And handle notifications from the nostr relay pool channel + cx.background_spawn({ + let client = client.clone(); + let gossip = Arc::clone(&gossip); + let tracker = Arc::clone(&tracker); + let _ = initialized_at(); + + async move { + // Connect to the bootstrap relays + Self::connect(&client).await; + + // Handle notifications from the relay pool + Self::handle_notifications(&client, &gossip, &tracker).await; + } + }), + ); + + Self { + client, + tracker, + gossip, + _tasks: tasks, + } + } + + /// Establish connection to the bootstrap relays + async fn connect(client: &Client) { + // Get all bootstrapping relays + let mut urls = vec![]; + urls.extend(BOOTSTRAP_RELAYS); + urls.extend(SEARCH_RELAYS); + + // Add relay to the relay pool + for url in urls.into_iter() { + client.add_relay(url).await.ok(); + } + + // Connect to all added relays + client.connect().await; + } + + async fn handle_notifications( + client: &Client, + gossip: &Arc>, + tracker: &Arc>, + ) { + let mut notifications = client.notifications(); + let mut processed_events = HashSet::new(); + + while let Ok(notification) = notifications.recv().await { + let RelayPoolNotification::Message { message, relay_url } = notification else { + // Skip if the notification is not a message + continue; + }; + + match message { + RelayMessage::Event { event, .. } => { + if !processed_events.insert(event.id) { + // Skip if the event has already been processed + continue; + } + + match event.kind { + Kind::RelayList => { + let mut gossip = gossip.write().await; + gossip.insert_relays(&event); + + let urls: Vec = Self::extract_write_relays(&event); + let author = event.pubkey; + + log::info!("Write relays: {urls:?}"); + + // Fetch user's encryption announcement event + Self::get(client, &urls, author, Kind::Custom(10044)).await; + // Fetch user's messaging relays event + Self::get(client, &urls, author, Kind::InboxRelays).await; + + // Verify if the event is belonging to the current user + if Self::is_self_authored(client, &event).await { + // Fetch user's metadata event + Self::get(client, &urls, author, Kind::Metadata).await; + // Fetch user's contact list event + Self::get(client, &urls, author, Kind::ContactList).await; + } + } + Kind::InboxRelays => { + let mut gossip = gossip.write().await; + gossip.insert_messaging_relays(&event); + + if Self::is_self_authored(client, &event).await { + // Extract user's messaging relays + let urls: Vec = + nip17::extract_relay_list(&event).cloned().collect(); + + // Fetch user's inbox messages in the extracted relays + Self::get_messages(client, event.pubkey, &urls).await; + } + } + Kind::Custom(10044) => { + let mut gossip = gossip.write().await; + gossip.insert_announcement(&event); + } + Kind::ContactList => { + if Self::is_self_authored(client, &event).await { + let public_keys: Vec = + event.tags.public_keys().copied().collect(); + + if let Err(e) = + Self::get_metadata_for_list(client, public_keys).await + { + log::error!("Failed to get metadata for list: {e}"); + } + } + } + _ => {} + }; + } + RelayMessage::Ok { + event_id, message, .. + } => { + let msg = MachineReadablePrefix::parse(&message); + let mut tracker = tracker.write().await; + + // Message that need to be authenticated will be handled separately + if let Some(MachineReadablePrefix::AuthRequired) = msg { + // Keep track of events that need to be resent after authentication + tracker.resend_queue.insert(event_id, relay_url); + } else { + // Keep track of events sent by Coop + tracker.sent_ids.insert(event_id); + } + } + _ => {} + } + } + } + + /// Check if event is published by current user + pub async fn is_self_authored(client: &Client, event: &Event) -> bool { + if let Ok(signer) = client.signer().await { + if let Ok(public_key) = signer.get_public_key().await { + return public_key == event.pubkey; + } + } + false + } + + /// Get event that match the given kind for a given author + async fn get(client: &Client, urls: &[RelayUrl], author: PublicKey, kind: Kind) { + // Skip if no relays are provided + if urls.is_empty() { + return; + } + + // Ensure relay connections + for url in urls.iter() { + client.add_relay(url).await.ok(); + client.connect_relay(url).await.ok(); + } + + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + let filter = Filter::new().author(author).kind(kind).limit(1); + + // Subscribe to filters from the user's write relays + if let Err(e) = client.subscribe_to(urls, filter, Some(opts)).await { + log::error!("Failed to subscribe: {}", e); + } + } + + /// Get all gift wrap events in the messaging relays for a given public key + pub async fn get_messages(client: &Client, public_key: PublicKey, urls: &[RelayUrl]) { + // Verify that there are relays provided + if urls.is_empty() { + return; + } + + // Ensure relay connection + for url in urls.iter() { + client.add_relay(url).await.ok(); + client.connect_relay(url).await.ok(); + } + + let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); + let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + + // Unsubscribe from the previous subscription + client.unsubscribe(&id).await; + + // Subscribe to filters to user's messaging relays + if let Err(e) = client.subscribe_with_id_to(urls, id, filter, None).await { + log::error!("Failed to subscribe: {}", e); + } else { + log::info!("Subscribed to gift wrap events for public key {public_key}",); + } + } + + /// Get metadata for a list of public keys + async fn get_metadata_for_list(client: &Client, pubkeys: Vec) -> Result<(), Error> { + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + let kinds = vec![Kind::Metadata, Kind::ContactList]; + + // Return if the list is empty + if pubkeys.is_empty() { + return Err(anyhow!("You need at least one public key".to_string(),)); + } + + let filter = Filter::new() + .limit(pubkeys.len() * kinds.len()) + .authors(pubkeys) + .kinds(kinds); + + // Subscribe to filters to the bootstrap relays + client + .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) + .await?; + + Ok(()) + } + + pub fn extract_read_relays(event: &Event) -> Vec { + nip65::extract_relay_list(event) + .filter_map(|(url, metadata)| { + if metadata.is_none() || metadata == &Some(RelayMetadata::Read) { + Some(url.to_owned()) + } else { + None + } + }) + .take(3) + .collect() + } + + pub fn extract_write_relays(event: &Event) -> Vec { + nip65::extract_relay_list(event) + .filter_map(|(url, metadata)| { + if metadata.is_none() || metadata == &Some(RelayMetadata::Write) { + Some(url.to_owned()) + } else { + None + } + }) + .take(3) + .collect() + } + + /// Extract an encryption keys announcement from an event. + pub fn extract_announcement(event: &Event) -> Result { + let public_key = event + .tags + .iter() + .find(|tag| tag.kind().as_str() == "n" || tag.kind().as_str() == "pubkey") + .and_then(|tag| tag.content()) + .and_then(|c| PublicKey::parse(c).ok()) + .context("Cannot parse public key from the event's tags")?; + + let client_name = event + .tags + .find(TagKind::Client) + .and_then(|tag| tag.content()) + .map(|c| c.to_string()); + + Ok(Announcement::new(event.id, client_name, public_key)) + } + + /// Extract an encryption keys response from an event. + pub async fn extract_response(client: &Client, event: &Event) -> Result { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + + if event.pubkey != public_key { + return Err(anyhow!("Event does not belong to current user")); + } + + let client_pubkey = event + .tags + .find(TagKind::custom("P")) + .and_then(|tag| tag.content()) + .and_then(|c| PublicKey::parse(c).ok()) + .context("Cannot parse public key from the event's tags")?; + + Ok(Response::new(event.content.clone(), client_pubkey)) + } + + /// Returns a reference to the nostr client. + pub fn client(&self) -> Client { + self.client.clone() + } + + /// Returns a reference to the event tracker. + pub fn tracker(&self) -> Arc> { + Arc::clone(&self.tracker) + } + + /// Returns a reference to the cache manager. + pub fn gossip(&self) -> Arc> { + Arc::clone(&self.gossip) + } +} diff --git a/crates/state/src/storage.rs b/crates/state_old/src/storage.rs similarity index 100% rename from crates/state/src/storage.rs rename to crates/state_old/src/storage.rs diff --git a/crates/state/src/tracker.rs b/crates/state_old/src/tracker.rs similarity index 100% rename from crates/state/src/tracker.rs rename to crates/state_old/src/tracker.rs -- 2.54.0 From 23f8cc49c6d62423a67bcef0d3a1352bb384fd8f Mon Sep 17 00:00:00 2001 From: reya Date: Mon, 5 Jan 2026 08:43:28 +0700 Subject: [PATCH 02/20] wip --- crates/chat/src/lib.rs | 7 +- crates/chat/src/room.rs | 6 +- crates/chat_ui/src/lib.rs | 116 +-------------------- crates/coop/src/chatspace.rs | 144 +++++++++------------------ crates/coop/src/main.rs | 6 -- crates/coop/src/sidebar/mod.rs | 7 +- crates/coop/src/user/mod.rs | 10 +- crates/coop/src/views/compose.rs | 6 +- crates/coop/src/views/setup_relay.rs | 10 +- crates/coop/src/views/startup.rs | 20 ++-- crates/state/src/identity.rs | 5 + crates/state/src/lib.rs | 69 +++++++++++-- 12 files changed, 142 insertions(+), 264 deletions(-) diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index c36accc7..bf398ab4 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -253,6 +253,11 @@ impl ChatRegistry { } } + /// Get the loading status of the chat registry + pub fn loading(&self) -> bool { + self.loading + } + /// Set the loading status of the chat registry pub fn set_loading(&mut self, loading: bool, cx: &mut Context) { self.loading = loading; @@ -511,7 +516,7 @@ impl ChatRegistry { } // Set this room is ongoing if the new message is from current user - if author == nostr.read(cx).identity(cx).public_key() { + if author == nostr.read(cx).identity().read(cx).public_key() { this.set_ongoing(cx); } diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 7e4b1446..d4e0960c 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -263,7 +263,7 @@ impl Room { pub fn display_member(&self, cx: &App) -> Profile { let persons = PersonRegistry::global(cx); let nostr = NostrRegistry::global(cx); - let public_key = nostr.read(cx).identity(cx).public_key(); + let public_key = nostr.read(cx).identity().read(cx).public_key(); let target_member = self .members @@ -369,7 +369,7 @@ impl Room { let nostr = NostrRegistry::global(cx); // Get current user - let public_key = nostr.read(cx).identity(cx).public_key(); + let public_key = nostr.read(cx).identity().read(cx).public_key(); // Get room's subject let subject = self.subject.clone(); @@ -438,7 +438,7 @@ impl Room { let client = nostr.read(cx).client(); // Get current user's public key and relays - let current_user = nostr.read(cx).identity(cx).public_key(); + let current_user = nostr.read(cx).identity().read(cx).public_key(); let current_user_relays = nostr.read(cx).messaging_relays(¤t_user, cx); let rumor = rumor.to_owned(); diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index aef827d9..6d038f62 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use std::time::Duration; pub use actions::*; -use chat::{Message, RenderedMessage, Room, RoomKind, RoomSignal, SendOptions, SendReport}; +use chat::{Message, RenderedMessage, Room, RoomKind, RoomSignal, SendReport}; use common::{nip96_upload, RenderedProfile, RenderedTimestamp}; use gpui::prelude::FluentBuilder; use gpui::{ @@ -59,7 +59,6 @@ pub struct ChatPanel { // New Message input: Entity, - options: Entity, replies_to: Entity>, // Media Attachment @@ -87,7 +86,6 @@ impl ChatPanel { let attachments = cx.new(|_| vec![]); let replies_to = cx.new(|_| HashSet::new()); - let options = cx.new(|_| SendOptions::default()); let id = room.read(cx).id.to_string().into(); let messages = BTreeSet::from([Message::system()]); @@ -145,18 +143,11 @@ impl ChatPanel { cx.subscribe_in(&room, window, move |this, _, signal, window, cx| { match signal { RoomSignal::NewMessage((gift_wrap_id, event)) => { - let nostr = NostrRegistry::global(cx); - let tracker = nostr.read(cx).tracker(); - let gift_wrap_id = gift_wrap_id.to_owned(); let message = Message::user(event.clone()); cx.spawn_in(window, async move |this, cx| { - let tracker = tracker.read().await; - this.update_in(cx, |this, _window, cx| { - if !tracker.sent_ids().contains(&gift_wrap_id) { - this.insert_message(message, false, cx); - } + this.insert_message(message, false, cx); }) .ok(); }) @@ -189,7 +180,6 @@ impl ChatPanel { input, replies_to, attachments, - options, rendered_texts_by_id: BTreeMap::new(), reports_by_id: BTreeMap::new(), uploading: false, @@ -271,14 +261,13 @@ impl ChatPanel { // Get the current room entity let room = self.room.read(cx); - let opts = self.options.read(cx); // Create a temporary message for optimistic update let rumor = room.create_message(&content, replies.as_ref(), cx); let rumor_id = rumor.id.unwrap(); // Create a task for sending the message in the background - let send_message = room.send_message(&rumor, opts, cx); + let send_message = room.send_message(&rumor, cx); // Optimistically update message list cx.spawn_in(window, async move |this, cx| { @@ -440,10 +429,6 @@ impl ChatPanel { persons.read(cx).get_person(public_key, cx) } - fn signer_kind(&self, cx: &App) -> SignerKind { - self.options.read(cx).signer_kind - } - fn scroll_to(&self, id: EventId) { if let Some(ix) = self.messages.iter().position(|m| { if let Message::User(msg) = m { @@ -1235,71 +1220,6 @@ impl ChatPanel { }); }) } - - fn on_open_seen_on(&mut self, ev: &SeenOn, window: &mut Window, cx: &mut Context) { - let id = ev.0; - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let tracker = nostr.read(cx).tracker(); - - let task: Task, Error>> = cx.background_spawn(async move { - let tracker = tracker.read().await; - let mut relays: Vec = vec![]; - - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .event(id) - .limit(1); - - if let Some(event) = client.database().query(filter).await?.first_owned() { - if let Some(Ok(id)) = event.tags.identifier().map(EventId::parse) { - if let Some(urls) = tracker.seen_on_relays.get(&id).cloned() { - relays.extend(urls); - } - } - } - - Ok(relays) - }); - - cx.spawn_in(window, async move |_, cx| { - if let Ok(urls) = task.await { - cx.update(|window, cx| { - window.open_modal(cx, move |this, _window, cx| { - this.show_close(true) - .title(SharedString::from("Seen on")) - .child(v_flex().pb_4().gap_2().children({ - let mut items = Vec::with_capacity(urls.len()); - - for url in urls.clone().into_iter() { - items.push( - h_flex() - .h_8() - .px_2() - .bg(cx.theme().elevated_surface_background) - .rounded(cx.theme().radius) - .font_semibold() - .text_xs() - .child(SharedString::from(url.to_string())), - ) - } - - items - })) - }); - }) - .ok(); - } - }) - .detach(); - } - - fn on_set_encryption(&mut self, ev: &SetSigner, _: &mut Window, cx: &mut Context) { - self.options.update(cx, move |this, cx| { - this.signer_kind = ev.0; - cx.notify(); - }); - } } impl Panel for ChatPanel { @@ -1339,11 +1259,7 @@ impl Focusable for ChatPanel { impl Render for ChatPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let kind = self.signer_kind(cx); - v_flex() - .on_action(cx.listener(Self::on_open_seen_on)) - .on_action(cx.listener(Self::on_set_encryption)) .image_cache(self.image_cache.clone()) .size_full() .child( @@ -1398,31 +1314,7 @@ impl Render for ChatPanel { .large(), ), ) - .child(TextInput::new(&self.input)) - .child( - Button::new("encryptions") - .icon(IconName::Encryption) - .ghost() - .large() - .popup_menu(move |this, _window, _cx| { - this.label("Encrypt by:") - .menu_with_check( - "Encryption Key", - matches!(kind, SignerKind::Encryption), - Box::new(SetSigner(SignerKind::Encryption)), - ) - .menu_with_check( - "User's Identity", - matches!(kind, SignerKind::User), - Box::new(SetSigner(SignerKind::User)), - ) - .menu_with_check( - "Auto", - matches!(kind, SignerKind::Auto), - Box::new(SetSigner(SignerKind::Auto)), - ) - }), - ), + .child(TextInput::new(&self.input)), ), ), ) diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs index 3195b990..68decc2f 100644 --- a/crates/coop/src/chatspace.rs +++ b/crates/coop/src/chatspace.rs @@ -1,12 +1,9 @@ use std::sync::Arc; -use account::Account; use auto_update::{AutoUpdateStatus, AutoUpdater}; use chat::{ChatEvent, ChatRegistry}; use chat_ui::{CopyPublicKey, OpenPublicKey}; use common::{RenderedProfile, DEFAULT_SIDEBAR_WIDTH}; -use encryption::Encryption; -use encryption_ui::EncryptionPanel; use gpui::prelude::FluentBuilder; use gpui::{ deferred, div, px, relative, rems, App, AppContext, Axis, ClipboardItem, Context, Entity, @@ -19,6 +16,7 @@ use person::PersonRegistry; use relay_auth::RelayAuth; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; +use state::NostrRegistry; use theme::{ActiveTheme, Theme, ThemeMode, ThemeRegistry}; use title_bar::TitleBar; use ui::avatar::Avatar; @@ -27,7 +25,6 @@ use ui::dock_area::dock::DockPlacement; use ui::dock_area::panel::PanelView; use ui::dock_area::{ClosePanel, DockArea, DockItem}; use ui::modal::ModalButtonProps; -use ui::popover::{Popover, PopoverContent}; use ui::popup_menu::PopupMenuExt; use ui::{h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt}; @@ -61,9 +58,6 @@ pub struct ChatSpace { /// App's Dock Area dock: Entity, - /// App's Encryption Panel - encryption_panel: Entity, - /// Determines if the chat space is ready to use ready: bool, @@ -73,13 +67,14 @@ pub struct ChatSpace { impl ChatSpace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); let chat = ChatRegistry::global(cx); let keystore = KeyStore::global(cx); - let account = Account::global(cx); let title_bar = cx.new(|_| TitleBar::new()); let dock = cx.new(|cx| DockArea::new(window, cx)); - let encryption_panel = encryption_ui::init(window, cx); + + let identity = nostr.read(cx).identity(); let mut subscriptions = smallvec![]; @@ -92,8 +87,8 @@ impl ChatSpace { subscriptions.push( // Observe account entity changes - cx.observe_in(&account, window, move |this, state, window, cx| { - if !this.ready && state.read(cx).has_account() { + cx.observe_in(&identity, window, move |this, state, window, cx| { + if !this.ready && state.read(cx).has_public_key() { this.set_default_layout(window, cx); // Load all chat room in the database if available @@ -175,7 +170,6 @@ impl ChatSpace { Self { dock, title_bar, - encryption_panel, ready: false, _subscriptions: subscriptions, } @@ -447,11 +441,11 @@ impl ChatSpace { } fn titlebar_left(&mut self, _window: &mut Window, cx: &Context) -> impl IntoElement { - let account = Account::global(cx); + let nostr = NostrRegistry::global(cx); let chat = ChatRegistry::global(cx); - let status = chat.read(cx).loading; + let status = chat.read(cx).loading(); - if !account.read(cx).has_account() { + if !nostr.read(cx).identity().read(cx).has_public_key() { return div(); } @@ -479,10 +473,12 @@ impl ChatSpace { fn titlebar_right(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let proxy = AppSettings::get_proxy_user_avatars(cx); let auto_update = AutoUpdater::global(cx); - let account = Account::global(cx); + let relay_auth = RelayAuth::global(cx); let pending_requests = relay_auth.read(cx).pending_requests(cx); - let encryption_panel = self.encryption_panel.downgrade(); + + let nostr = NostrRegistry::global(cx); + let identity = nostr.read(cx).identity(); h_flex() .gap_2() @@ -542,16 +538,10 @@ impl ChatSpace { }), ) }) - .when(account.read(cx).has_account(), |this| { - let account = Account::global(cx); - let public_key = account.read(cx).public_key(); - + .when_some(identity.read(cx).option_public_key(), |this, public_key| { let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get_person(&public_key, cx); - let encryption = Encryption::global(cx); - let has_encryption = encryption.read(cx).has_encryption(cx); - let keystore = KeyStore::global(cx); let is_using_file_keystore = keystore.read(cx).is_using_file_keystore(); @@ -562,82 +552,38 @@ impl ChatSpace { }; this.child( - h_flex() - .gap_1() - .child( - Popover::new("encryption") - .trigger( - Button::new("encryption-trigger") - .tooltip("Manage Encryption Key") - .icon(IconName::Encryption) - .rounded() - .small() - .cta() - .map(|this| match has_encryption { - true => this.ghost_alt(), - false => this.warning(), - }), + Button::new("user") + .small() + .reverse() + .transparent() + .icon(IconName::CaretDown) + .child(Avatar::new(profile.avatar(proxy)).size(rems(1.45))) + .popup_menu(move |this, _window, _cx| { + this.label(profile.display_name()) + .menu_with_icon( + "Profile", + IconName::EmojiFill, + Box::new(ViewProfile), ) - .content(move |window, cx| { - let encryption_panel = encryption_panel.clone(); - - cx.new(|cx| { - PopoverContent::new(window, cx, move |_window, _cx| { - if let Some(view) = encryption_panel.upgrade() { - view.clone().into_any_element() - } else { - div().into_any_element() - } - }) - }) - }), - ) - .child( - Button::new("user") - .small() - .reverse() - .transparent() - .icon(IconName::CaretDown) - .child(Avatar::new(profile.avatar(proxy)).size(rems(1.45))) - .popup_menu(move |this, _window, _cx| { - this.label(profile.display_name()) - .menu_with_icon( - "Profile", - IconName::EmojiFill, - Box::new(ViewProfile), - ) - .menu_with_icon( - "Messaging Relays", - IconName::Server, - Box::new(ViewRelays), - ) - .separator() - .label(SharedString::from("Keyring Service")) - .menu_with_icon_and_disabled( - keyring_label.clone(), - IconName::Encryption, - Box::new(KeyringPopup), - !is_using_file_keystore, - ) - .separator() - .menu_with_icon( - "Dark Mode", - IconName::Sun, - Box::new(DarkMode), - ) - .menu_with_icon("Themes", IconName::Moon, Box::new(Themes)) - .menu_with_icon( - "Settings", - IconName::Settings, - Box::new(Settings), - ) - .menu_with_icon( - "Sign Out", - IconName::Logout, - Box::new(Logout), - ) - }), - ), + .menu_with_icon( + "Messaging Relays", + IconName::Server, + Box::new(ViewRelays), + ) + .separator() + .label(SharedString::from("Keyring Service")) + .menu_with_icon_and_disabled( + keyring_label.clone(), + IconName::Encryption, + Box::new(KeyringPopup), + !is_using_file_keystore, + ) + .separator() + .menu_with_icon("Dark Mode", IconName::Sun, Box::new(DarkMode)) + .menu_with_icon("Themes", IconName::Moon, Box::new(Themes)) + .menu_with_icon("Settings", IconName::Settings, Box::new(Settings)) + .menu_with_icon("Sign Out", IconName::Logout, Box::new(Logout)) + }), ) }) } diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index ad97ebbe..845ba747 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -96,12 +96,6 @@ fn main() { // Initialize settings settings::init(cx); - // Initialize account state - account::init(cx); - - // Initialize encryption state - encryption::init(cx); - // Initialize app registry chat::init(cx); diff --git a/crates/coop/src/sidebar/mod.rs b/crates/coop/src/sidebar/mod.rs index f1a1bd33..e7e5c394 100644 --- a/crates/coop/src/sidebar/mod.rs +++ b/crates/coop/src/sidebar/mod.rs @@ -1,7 +1,6 @@ use std::ops::Range; use std::time::Duration; -use account::Account; use anyhow::{anyhow, Error}; use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use common::{DebouncedDelay, RenderedTimestamp, TextUtils, BOOTSTRAP_RELAYS, SEARCH_RELAYS}; @@ -199,11 +198,9 @@ impl Sidebar { } fn search_by_nip50(&mut self, query: &str, window: &mut Window, cx: &mut Context) { - let account = Account::global(cx); - let public_key = account.read(cx).public_key(); - let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); + let public_key = nostr.read(cx).identity().read(cx).public_key(); let query = query.to_owned(); @@ -597,7 +594,7 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let chat = ChatRegistry::global(cx); - let loading = chat.read(cx).loading; + let loading = chat.read(cx).loading(); // Get rooms from either search results or the chat registry let rooms = if let Some(results) = self.search_results.read(cx).as_ref() { diff --git a/crates/coop/src/user/mod.rs b/crates/coop/src/user/mod.rs index d4f58402..c94840b7 100644 --- a/crates/coop/src/user/mod.rs +++ b/crates/coop/src/user/mod.rs @@ -259,17 +259,11 @@ impl UserProfile { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let gossip = nostr.read(cx).gossip(); + let public_key = nostr.read(cx).identity().read(cx).public_key(); + let write_relays = nostr.read(cx).write_relays(&public_key, cx); cx.background_spawn(async move { let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - let gossip = gossip.read().await; - let write_relays = gossip.inbox_relays(&public_key); - - // Ensure connections to the write relays - gossip.ensure_connections(&client, &write_relays).await; // Sign the new metadata event let event = EventBuilder::metadata(&new_metadata).sign(&signer).await?; diff --git a/crates/coop/src/views/compose.rs b/crates/coop/src/views/compose.rs index e61b0a43..5d260a24 100644 --- a/crates/coop/src/views/compose.rs +++ b/crates/coop/src/views/compose.rs @@ -1,7 +1,6 @@ use std::ops::Range; use std::time::Duration; -use account::Account; use anyhow::{anyhow, Error}; use chat::{ChatRegistry, Room}; use common::{nip05_profile, RenderedProfile, TextUtils, BOOTSTRAP_RELAYS}; @@ -312,9 +311,8 @@ impl Compose { fn submit(&mut self, window: &mut Window, cx: &mut Context) { let chat = ChatRegistry::global(cx); - - let account = Account::global(cx); - let public_key = account.read(cx).public_key(); + let nostr = NostrRegistry::global(cx); + let public_key = nostr.read(cx).identity().read(cx).public_key(); let receivers: Vec = self.selected(cx); let subject_input = self.title_input.read(cx).value(); diff --git a/crates/coop/src/views/setup_relay.rs b/crates/coop/src/views/setup_relay.rs index 6218be57..c1205e7e 100644 --- a/crates/coop/src/views/setup_relay.rs +++ b/crates/coop/src/views/setup_relay.rs @@ -158,17 +158,13 @@ impl SetupRelay { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let gossip = nostr.read(cx).gossip(); + let public_key = nostr.read(cx).identity().read(cx).public_key(); + let write_relays = nostr.read(cx).write_relays(&public_key, cx); + let relays = self.relays.clone(); let task: Task> = cx.background_spawn(async move { let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - let gossip = gossip.read().await; - let write_relays = gossip.inbox_relays(&public_key); - - // Ensure connections to the write relays - gossip.ensure_connections(&client, &write_relays).await; let tags: Vec = relays .iter() diff --git a/crates/coop/src/views/startup.rs b/crates/coop/src/views/startup.rs index aa2c87fa..b67db157 100644 --- a/crates/coop/src/views/startup.rs +++ b/crates/coop/src/views/startup.rs @@ -29,11 +29,16 @@ pub fn init(cre: Credential, window: &mut Window, cx: &mut App) -> Entity, /// Event subscriptions @@ -164,15 +169,12 @@ impl Startup { } fn login_with_keys(&mut self, secret: SecretKey, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); let keys = Keys::new(secret); + let nostr = NostrRegistry::global(cx); - // Update the signer - cx.background_spawn(async move { - client.set_signer(keys).await; + nostr.update(cx, |this, cx| { + this.set_signer(keys, cx); }) - .detach(); } fn set_loading(&mut self, status: bool, cx: &mut Context) { diff --git a/crates/state/src/identity.rs b/crates/state/src/identity.rs index 2d296b4e..f5946023 100644 --- a/crates/state/src/identity.rs +++ b/crates/state/src/identity.rs @@ -75,6 +75,11 @@ impl Identity { self.public_key = None; } + /// Returns the public key of the identity. + pub fn option_public_key(&self) -> Option { + self.public_key + } + /// Returns the public key of the identity. pub fn public_key(&self) -> PublicKey { // This method is safe to unwrap because the public key is always called when the identity is created. diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 67201b88..29cd2886 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -286,8 +286,8 @@ impl NostrRegistry { } /// Get current identity - pub fn identity(&self, cx: &App) -> Identity { - self.identity.read(cx).clone() + pub fn identity(&self) -> Entity { + self.identity.clone() } /// Get a relay hint (messaging relay) for a given public key @@ -299,9 +299,58 @@ impl NostrRegistry { .cloned() } + /// Get a list of write relays for a given public key + pub fn write_relays(&self, public_key: &PublicKey, cx: &App) -> Vec { + let client = self.client(); + let relays = self.gossip.read(cx).write_relays(public_key); + let async_relays = relays.clone(); + + // Ensure relay connections + cx.background_spawn(async move { + for url in async_relays.iter() { + client.add_relay(url).await.ok(); + client.connect_relay(url).await.ok(); + } + }) + .detach(); + + relays + } + + /// Get a list of read relays for a given public key + pub fn read_relays(&self, public_key: &PublicKey, cx: &App) -> Vec { + let client = self.client(); + let relays = self.gossip.read(cx).read_relays(public_key); + let async_relays = relays.clone(); + + // Ensure relay connections + cx.background_spawn(async move { + for url in async_relays.iter() { + client.add_relay(url).await.ok(); + client.connect_relay(url).await.ok(); + } + }) + .detach(); + + relays + } + /// Get a list of messaging relays for a given public key pub fn messaging_relays(&self, public_key: &PublicKey, cx: &App) -> Vec { - self.gossip.read(cx).messaging_relays(public_key) + let client = self.client(); + let relays = self.gossip.read(cx).messaging_relays(public_key); + let async_relays = relays.clone(); + + // Ensure relay connections + cx.background_spawn(async move { + for url in async_relays.iter() { + client.add_relay(url).await.ok(); + client.connect_relay(url).await.ok(); + } + }) + .detach(); + + relays } /// Set the signer for the nostr client and verify the public key @@ -370,7 +419,7 @@ impl NostrRegistry { fn get_relay_list(&mut self, cx: &mut Context) { let client = self.client(); let async_identity = self.identity.downgrade(); - let public_key = self.identity(cx).public_key(); + let public_key = self.identity().read(cx).public_key(); let task: Task> = cx.background_spawn(async move { let filter = Filter::new() @@ -415,8 +464,8 @@ impl NostrRegistry { fn get_messaging_relays(&mut self, cx: &mut Context) { let client = self.client(); let async_identity = self.identity.downgrade(); - let public_key = self.identity(cx).public_key(); - let write_relays = self.gossip.read(cx).write_relays(&public_key); + let public_key = self.identity().read(cx).public_key(); + let write_relays = self.write_relays(&public_key, cx); let task: Task> = cx.background_spawn(async move { let filter = Filter::new() @@ -460,8 +509,8 @@ impl NostrRegistry { /// Continuously get gift wrap events for the current user in their messaging relays fn get_messages(&mut self, cx: &mut Context) { let client = self.client(); - let public_key = self.identity(cx).public_key(); - let messaging_relays = self.gossip.read(cx).messaging_relays(&public_key); + let public_key = self.identity().read(cx).public_key(); + let messaging_relays = self.messaging_relays(&public_key, cx); cx.background_spawn(async move { let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); @@ -480,7 +529,7 @@ impl NostrRegistry { /// Publish an event to author's write relays pub fn publish(&self, event: Event, cx: &App) -> Task, Error>> { let client = self.client(); - let write_relays = self.gossip.read(cx).write_relays(&event.pubkey); + let write_relays = self.write_relays(&event.pubkey, cx); cx.background_spawn(async move { Ok(client.send_event_to(&write_relays, &event).await?) }) } @@ -491,7 +540,7 @@ impl NostrRegistry { I: Into>, { let client = self.client(); - let write_relays = self.gossip.read(cx).write_relays(&author); + let write_relays = self.write_relays(&author, cx); // Construct filters based on event kinds let filters: Vec = kinds -- 2.54.0 From e1ed8483bae2ed49b6ba1e06e0bd5a7109da89bd Mon Sep 17 00:00:00 2001 From: reya Date: Mon, 5 Jan 2026 10:51:25 +0700 Subject: [PATCH 03/20] clean up --- Cargo.lock | 17 -- crates/chat/src/lib.rs | 21 +- crates/chat/src/message.rs | 80 ++++--- crates/chat/src/room.rs | 26 ++- crates/chat_ui/src/lib.rs | 31 +-- crates/coop/src/sidebar/mod.rs | 2 +- crates/state_old/Cargo.toml | 21 -- crates/state_old/src/lib.rs | 388 -------------------------------- crates/state_old/src/storage.rs | 189 ---------------- crates/state_old/src/tracker.rs | 43 ---- 10 files changed, 96 insertions(+), 722 deletions(-) delete mode 100644 crates/state_old/Cargo.toml delete mode 100644 crates/state_old/src/lib.rs delete mode 100644 crates/state_old/src/storage.rs delete mode 100644 crates/state_old/src/tracker.rs diff --git a/Cargo.lock b/Cargo.lock index 5c039d7b..f3634501 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6024,23 +6024,6 @@ dependencies = [ "smol", ] -[[package]] -name = "state_old" -version = "0.3.0" -dependencies = [ - "anyhow", - "common", - "gpui", - "log", - "nostr-lmdb", - "nostr-sdk", - "rustls", - "serde", - "serde_json", - "smallvec", - "smol", -] - [[package]] name = "static_assertions" version = "1.1.0" diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index bf398ab4..5e96710a 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -11,9 +11,7 @@ use flume::Sender; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; -pub use message::*; use nostr_sdk::prelude::*; -pub use room::*; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; use state::{tracker, NostrRegistry, GIFTWRAP_SUBSCRIPTION}; @@ -21,6 +19,9 @@ use state::{tracker, NostrRegistry, GIFTWRAP_SUBSCRIPTION}; mod message; mod room; +pub use message::*; +pub use room::*; + pub fn init(cx: &mut App) { ChatRegistry::set_global(cx.new(ChatRegistry::new), cx); } @@ -42,17 +43,22 @@ pub struct ChatRegistry { _tasks: SmallVec<[Task<()>; 4]>, } +/// Chat event. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ChatEvent { OpenRoom(u64), CloseRoom(u64), - NewChatRequest(RoomKind), + NewRequest(RoomKind), } +/// Channel signal. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub enum Signal { - Loading(bool), +enum Signal { + /// Message received from relay pool Message(NewMessage), + /// Loading status of the registry + Loading(bool), + /// Eose received from relay pool Eose, } @@ -507,7 +513,6 @@ impl ChatRegistry { if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) { let is_new_event = message.rumor.created_at > room.read(cx).created_at; let created_at = message.rumor.created_at; - let event_for_emit = message.rumor.clone(); // Update room room.update(cx, |this, cx| { @@ -521,7 +526,7 @@ impl ChatRegistry { } // Emit the new message to the room - this.emit_message(message.gift_wrap, event_for_emit.clone(), cx); + this.emit_message(message, cx); }); // Resort all rooms in the registry by their created at (after updated) @@ -533,7 +538,7 @@ impl ChatRegistry { self.add_room(cx.new(|_| Room::from(&message.rumor)), cx); // Notify the UI about the new room - cx.emit(ChatEvent::NewChatRequest(RoomKind::default())); + cx.emit(ChatEvent::NewRequest(RoomKind::default())); } } diff --git a/crates/chat/src/message.rs b/crates/chat/src/message.rs index b0e34efc..c4cfef64 100644 --- a/crates/chat/src/message.rs +++ b/crates/chat/src/message.rs @@ -2,6 +2,7 @@ use std::hash::Hash; use nostr_sdk::prelude::*; +/// New message. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct NewMessage { pub gift_wrap: EventId, @@ -14,6 +15,7 @@ impl NewMessage { } } +/// Message. #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub enum Message { User(RenderedMessage), @@ -22,11 +24,17 @@ pub enum Message { } impl Message { - pub fn user(user: impl Into) -> Self { + pub fn user(user: I) -> Self + where + I: Into, + { Self::User(user.into()) } - pub fn warning(content: impl Into) -> Self { + pub fn warning(content: I) -> Self + where + I: Into, + { Self::Warning(content.into(), Timestamp::now()) } @@ -43,6 +51,18 @@ impl Message { } } +impl From<&NewMessage> for Message { + fn from(val: &NewMessage) -> Self { + Self::User(val.into()) + } +} + +impl From<&UnsignedEvent> for Message { + fn from(val: &UnsignedEvent) -> Self { + Self::User(val.into()) + } +} + impl Ord for Message { fn cmp(&self, other: &Self) -> std::cmp::Ordering { match (self, other) { @@ -63,6 +83,7 @@ impl PartialOrd for Message { } } +/// Rendered message. #[derive(Debug, Clone)] pub struct RenderedMessage { pub id: EventId, @@ -78,48 +99,53 @@ pub struct RenderedMessage { pub replies_to: Vec, } -impl From for RenderedMessage { - fn from(inner: Event) -> Self { - let mentions = extract_mentions(&inner.content); - let replies_to = extract_reply_ids(&inner.tags); +impl From<&Event> for RenderedMessage { + fn from(val: &Event) -> Self { + let mentions = extract_mentions(&val.content); + let replies_to = extract_reply_ids(&val.tags); Self { - id: inner.id, - author: inner.pubkey, - content: inner.content, - created_at: inner.created_at, + id: val.id, + author: val.pubkey, + content: val.content.clone(), + created_at: val.created_at, mentions, replies_to, } } } -impl From for RenderedMessage { - fn from(inner: UnsignedEvent) -> Self { - let mentions = extract_mentions(&inner.content); - let replies_to = extract_reply_ids(&inner.tags); +impl From<&UnsignedEvent> for RenderedMessage { + fn from(val: &UnsignedEvent) -> Self { + let mentions = extract_mentions(&val.content); + let replies_to = extract_reply_ids(&val.tags); Self { // Event ID must be known - id: inner.id.unwrap(), - author: inner.pubkey, - content: inner.content, - created_at: inner.created_at, + id: val.id.unwrap(), + author: val.pubkey, + content: val.content.clone(), + created_at: val.created_at, mentions, replies_to, } } } -impl From> for RenderedMessage { - fn from(inner: Box) -> Self { - (*inner).into() - } -} +impl From<&NewMessage> for RenderedMessage { + fn from(val: &NewMessage) -> Self { + let mentions = extract_mentions(&val.rumor.content); + let replies_to = extract_reply_ids(&val.rumor.tags); -impl From<&Box> for RenderedMessage { - fn from(inner: &Box) -> Self { - inner.to_owned().into() + Self { + // Event ID must be known + id: val.rumor.id.unwrap(), + author: val.rumor.pubkey, + content: val.rumor.content.clone(), + created_at: val.rumor.created_at, + mentions, + replies_to, + } } } @@ -149,6 +175,7 @@ impl Hash for RenderedMessage { } } +/// Extracts all mentions (public keys) from a content string. fn extract_mentions(content: &str) -> Vec { let parser = NostrParser::new(); let tokens = parser.parse(content); @@ -165,6 +192,7 @@ fn extract_mentions(content: &str) -> Vec { .collect::>() } +/// Extracts all reply (ids) from the event tags. fn extract_reply_ids(inner: &Tags) -> Vec { let mut replies_to = vec![]; diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index d4e0960c..427d950e 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -11,6 +11,8 @@ use nostr_sdk::prelude::*; use person::PersonRegistry; use state::{tracker, NostrRegistry}; +use crate::NewMessage; + const SEND_RETRY: usize = 10; #[derive(Debug, Clone)] @@ -80,17 +82,21 @@ impl SendReport { } } -#[derive(Debug, Clone)] -pub enum RoomSignal { - NewMessage((EventId, UnsignedEvent)), - Refresh, +/// Room event. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum RoomEvent { + /// Incoming message. + Incoming(NewMessage), + /// Reloads the current room's messages. + Reload, } +/// Room kind. #[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)] pub enum RoomKind { - Ongoing, #[default] Request, + Ongoing, } #[derive(Debug)] @@ -133,7 +139,7 @@ impl Hash for Room { impl Eq for Room {} -impl EventEmitter for Room {} +impl EventEmitter for Room {} impl From<&UnsignedEvent> for Room { fn from(val: &UnsignedEvent) -> Self { @@ -304,13 +310,13 @@ impl Room { } /// Emits a new message signal to the current room - pub fn emit_message(&self, id: EventId, event: UnsignedEvent, cx: &mut Context) { - cx.emit(RoomSignal::NewMessage((id, event))); + pub fn emit_message(&self, message: NewMessage, cx: &mut Context) { + cx.emit(RoomEvent::Incoming(message)); } - /// Emits a signal to refresh the current room's messages. + /// Emits a signal to reload the current room's messages. pub fn emit_refresh(&mut self, cx: &mut Context) { - cx.emit(RoomSignal::Refresh); + cx.emit(RoomEvent::Reload); } /// Get gossip relays for each member diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 6d038f62..36f6609a 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use std::time::Duration; pub use actions::*; -use chat::{Message, RenderedMessage, Room, RoomKind, RoomSignal, SendReport}; +use chat::{Message, RenderedMessage, Room, RoomEvent, RoomKind, SendReport}; use common::{nip96_upload, RenderedProfile, RenderedTimestamp}; use gpui::prelude::FluentBuilder; use gpui::{ @@ -114,7 +114,7 @@ impl ChatPanel { this.update_in(cx, |this, window, cx| { match result { Ok(events) => { - this.insert_messages(events, cx); + this.insert_messages(&events, cx); } Err(e) => { window.push_notification(e.to_string(), cx); @@ -142,18 +142,10 @@ impl ChatPanel { // Subscribe to room events cx.subscribe_in(&room, window, move |this, _, signal, window, cx| { match signal { - RoomSignal::NewMessage((gift_wrap_id, event)) => { - let message = Message::user(event.clone()); - - cx.spawn_in(window, async move |this, cx| { - this.update_in(cx, |this, _window, cx| { - this.insert_message(message, false, cx); - }) - .ok(); - }) - .detach(); + RoomEvent::Incoming(message) => { + this.insert_message(message, false, cx); } - RoomSignal::Refresh => { + RoomEvent::Reload => { this.load_messages(window, cx); } }; @@ -202,7 +194,7 @@ impl ChatPanel { this.update_in(cx, |this, window, cx| { match result { Ok(events) => { - this.insert_messages(events, cx); + this.insert_messages(&events, cx); } Err(e) => { window.push_notification(Notification::error(e.to_string()), cx); @@ -278,14 +270,16 @@ impl ChatPanel { // Update the message list and reset the states this.update_in(cx, |this, window, cx| { - this.insert_message(Message::user(rumor), true, cx); this.remove_all_replies(cx); this.remove_all_attachments(cx); + // Reset the input to its default state this.input.update(cx, |this, cx| { this.set_loading(false, cx); this.set_disabled(false, cx); this.set_value("", window, cx); }); + // Update the message list + this.insert_message(&rumor, true, cx); }) .ok(); }) @@ -378,11 +372,10 @@ impl ChatPanel { } /// Convert and insert a vector of nostr events into the chat panel - fn insert_messages(&mut self, events: Vec, cx: &mut Context) { - for event in events { - let m = Message::user(event); + fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context) { + for event in events.iter() { // Bulk inserting messages, so no need to scroll to the latest message - self.insert_message(m, false, cx); + self.insert_message(event, false, cx); } } diff --git a/crates/coop/src/sidebar/mod.rs b/crates/coop/src/sidebar/mod.rs index e7e5c394..b4b56719 100644 --- a/crates/coop/src/sidebar/mod.rs +++ b/crates/coop/src/sidebar/mod.rs @@ -84,7 +84,7 @@ impl Sidebar { subscriptions.push( // Subscribe for registry new events cx.subscribe_in(&chat, window, move |this, _, event, _window, cx| { - if let ChatEvent::NewChatRequest(kind) = event { + if let ChatEvent::NewRequest(kind) = event { this.indicator.update(cx, |this, cx| { *this = Some(kind.to_owned()); cx.notify(); diff --git a/crates/state_old/Cargo.toml b/crates/state_old/Cargo.toml deleted file mode 100644 index 5f668cd9..00000000 --- a/crates/state_old/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "state_old" -version.workspace = true -edition.workspace = true -publish.workspace = true - -[dependencies] -common = { path = "../common" } - -nostr-sdk.workspace = true -nostr-lmdb.workspace = true - -gpui.workspace = true -smol.workspace = true -smallvec.workspace = true -log.workspace = true -anyhow.workspace = true -serde.workspace = true -serde_json.workspace = true - -rustls = "0.23.23" diff --git a/crates/state_old/src/lib.rs b/crates/state_old/src/lib.rs deleted file mode 100644 index de10ecd5..00000000 --- a/crates/state_old/src/lib.rs +++ /dev/null @@ -1,388 +0,0 @@ -use std::collections::HashSet; -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{anyhow, Context as AnyhowContext, Error}; -use common::{config_dir, BOOTSTRAP_RELAYS, SEARCH_RELAYS}; -use gpui::{App, AppContext, Context, Entity, Global, Task}; -use nostr_lmdb::NostrLmdb; -use nostr_sdk::prelude::*; -use smallvec::{smallvec, SmallVec}; -use smol::lock::RwLock; -pub use storage::*; -pub use tracker::*; - -mod storage; -mod tracker; - -pub const GIFTWRAP_SUBSCRIPTION: &str = "gift-wrap-events"; - -pub fn init(cx: &mut App) { - NostrRegistry::set_global(cx.new(NostrRegistry::new), cx); -} - -struct GlobalNostrRegistry(Entity); - -impl Global for GlobalNostrRegistry {} - -/// Nostr Registry -#[derive(Debug)] -pub struct NostrRegistry { - /// Nostr Client - client: Client, - - /// Custom gossip implementation - gossip: Arc>, - - /// Tracks activity related to Nostr events - tracker: Arc>, - - /// Tasks for asynchronous operations - _tasks: SmallVec<[Task<()>; 1]>, -} - -impl NostrRegistry { - /// Retrieve the global nostr state - pub fn global(cx: &App) -> Entity { - cx.global::().0.clone() - } - - /// Set the global nostr instance - fn set_global(state: Entity, cx: &mut App) { - cx.set_global(GlobalNostrRegistry(state)); - } - - /// Create a new nostr instance - fn new(cx: &mut Context) -> Self { - // rustls uses the `aws_lc_rs` provider by default - // This only errors if the default provider has already - // been installed. We can ignore this `Result`. - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .ok(); - - // Construct the nostr client options - let opts = ClientOptions::new() - .automatic_authentication(false) - .verify_subscriptions(false) - .sleep_when_idle(SleepWhenIdle::Enabled { - timeout: Duration::from_secs(600), - }); - - // Construct the lmdb - let lmdb = cx.background_executor().block(async move { - let path = config_dir().join("nostr"); - NostrLmdb::open(path) - .await - .expect("Failed to initialize database") - }); - - // Construct the nostr client - let client = ClientBuilder::default().database(lmdb).opts(opts).build(); - - let tracker = Arc::new(RwLock::new(EventTracker::default())); - let gossip = Arc::new(RwLock::new(Gossip::default())); - - let mut tasks = smallvec![]; - - tasks.push( - // Establish connection to the bootstrap relays - // - // And handle notifications from the nostr relay pool channel - cx.background_spawn({ - let client = client.clone(); - let gossip = Arc::clone(&gossip); - let tracker = Arc::clone(&tracker); - let _ = initialized_at(); - - async move { - // Connect to the bootstrap relays - Self::connect(&client).await; - - // Handle notifications from the relay pool - Self::handle_notifications(&client, &gossip, &tracker).await; - } - }), - ); - - Self { - client, - tracker, - gossip, - _tasks: tasks, - } - } - - /// Establish connection to the bootstrap relays - async fn connect(client: &Client) { - // Get all bootstrapping relays - let mut urls = vec![]; - urls.extend(BOOTSTRAP_RELAYS); - urls.extend(SEARCH_RELAYS); - - // Add relay to the relay pool - for url in urls.into_iter() { - client.add_relay(url).await.ok(); - } - - // Connect to all added relays - client.connect().await; - } - - async fn handle_notifications( - client: &Client, - gossip: &Arc>, - tracker: &Arc>, - ) { - let mut notifications = client.notifications(); - let mut processed_events = HashSet::new(); - - while let Ok(notification) = notifications.recv().await { - let RelayPoolNotification::Message { message, relay_url } = notification else { - // Skip if the notification is not a message - continue; - }; - - match message { - RelayMessage::Event { event, .. } => { - if !processed_events.insert(event.id) { - // Skip if the event has already been processed - continue; - } - - match event.kind { - Kind::RelayList => { - let mut gossip = gossip.write().await; - gossip.insert_relays(&event); - - let urls: Vec = Self::extract_write_relays(&event); - let author = event.pubkey; - - log::info!("Write relays: {urls:?}"); - - // Fetch user's encryption announcement event - Self::get(client, &urls, author, Kind::Custom(10044)).await; - // Fetch user's messaging relays event - Self::get(client, &urls, author, Kind::InboxRelays).await; - - // Verify if the event is belonging to the current user - if Self::is_self_authored(client, &event).await { - // Fetch user's metadata event - Self::get(client, &urls, author, Kind::Metadata).await; - // Fetch user's contact list event - Self::get(client, &urls, author, Kind::ContactList).await; - } - } - Kind::InboxRelays => { - let mut gossip = gossip.write().await; - gossip.insert_messaging_relays(&event); - - if Self::is_self_authored(client, &event).await { - // Extract user's messaging relays - let urls: Vec = - nip17::extract_relay_list(&event).cloned().collect(); - - // Fetch user's inbox messages in the extracted relays - Self::get_messages(client, event.pubkey, &urls).await; - } - } - Kind::Custom(10044) => { - let mut gossip = gossip.write().await; - gossip.insert_announcement(&event); - } - Kind::ContactList => { - if Self::is_self_authored(client, &event).await { - let public_keys: Vec = - event.tags.public_keys().copied().collect(); - - if let Err(e) = - Self::get_metadata_for_list(client, public_keys).await - { - log::error!("Failed to get metadata for list: {e}"); - } - } - } - _ => {} - }; - } - RelayMessage::Ok { - event_id, message, .. - } => { - let msg = MachineReadablePrefix::parse(&message); - let mut tracker = tracker.write().await; - - // Message that need to be authenticated will be handled separately - if let Some(MachineReadablePrefix::AuthRequired) = msg { - // Keep track of events that need to be resent after authentication - tracker.resend_queue.insert(event_id, relay_url); - } else { - // Keep track of events sent by Coop - tracker.sent_ids.insert(event_id); - } - } - _ => {} - } - } - } - - /// Check if event is published by current user - pub async fn is_self_authored(client: &Client, event: &Event) -> bool { - if let Ok(signer) = client.signer().await { - if let Ok(public_key) = signer.get_public_key().await { - return public_key == event.pubkey; - } - } - false - } - - /// Get event that match the given kind for a given author - async fn get(client: &Client, urls: &[RelayUrl], author: PublicKey, kind: Kind) { - // Skip if no relays are provided - if urls.is_empty() { - return; - } - - // Ensure relay connections - for url in urls.iter() { - client.add_relay(url).await.ok(); - client.connect_relay(url).await.ok(); - } - - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let filter = Filter::new().author(author).kind(kind).limit(1); - - // Subscribe to filters from the user's write relays - if let Err(e) = client.subscribe_to(urls, filter, Some(opts)).await { - log::error!("Failed to subscribe: {}", e); - } - } - - /// Get all gift wrap events in the messaging relays for a given public key - pub async fn get_messages(client: &Client, public_key: PublicKey, urls: &[RelayUrl]) { - // Verify that there are relays provided - if urls.is_empty() { - return; - } - - // Ensure relay connection - for url in urls.iter() { - client.add_relay(url).await.ok(); - client.connect_relay(url).await.ok(); - } - - let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); - let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); - - // Unsubscribe from the previous subscription - client.unsubscribe(&id).await; - - // Subscribe to filters to user's messaging relays - if let Err(e) = client.subscribe_with_id_to(urls, id, filter, None).await { - log::error!("Failed to subscribe: {}", e); - } else { - log::info!("Subscribed to gift wrap events for public key {public_key}",); - } - } - - /// Get metadata for a list of public keys - async fn get_metadata_for_list(client: &Client, pubkeys: Vec) -> Result<(), Error> { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let kinds = vec![Kind::Metadata, Kind::ContactList]; - - // Return if the list is empty - if pubkeys.is_empty() { - return Err(anyhow!("You need at least one public key".to_string(),)); - } - - let filter = Filter::new() - .limit(pubkeys.len() * kinds.len()) - .authors(pubkeys) - .kinds(kinds); - - // Subscribe to filters to the bootstrap relays - client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await?; - - Ok(()) - } - - pub fn extract_read_relays(event: &Event) -> Vec { - nip65::extract_relay_list(event) - .filter_map(|(url, metadata)| { - if metadata.is_none() || metadata == &Some(RelayMetadata::Read) { - Some(url.to_owned()) - } else { - None - } - }) - .take(3) - .collect() - } - - pub fn extract_write_relays(event: &Event) -> Vec { - nip65::extract_relay_list(event) - .filter_map(|(url, metadata)| { - if metadata.is_none() || metadata == &Some(RelayMetadata::Write) { - Some(url.to_owned()) - } else { - None - } - }) - .take(3) - .collect() - } - - /// Extract an encryption keys announcement from an event. - pub fn extract_announcement(event: &Event) -> Result { - let public_key = event - .tags - .iter() - .find(|tag| tag.kind().as_str() == "n" || tag.kind().as_str() == "pubkey") - .and_then(|tag| tag.content()) - .and_then(|c| PublicKey::parse(c).ok()) - .context("Cannot parse public key from the event's tags")?; - - let client_name = event - .tags - .find(TagKind::Client) - .and_then(|tag| tag.content()) - .map(|c| c.to_string()); - - Ok(Announcement::new(event.id, client_name, public_key)) - } - - /// Extract an encryption keys response from an event. - pub async fn extract_response(client: &Client, event: &Event) -> Result { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - if event.pubkey != public_key { - return Err(anyhow!("Event does not belong to current user")); - } - - let client_pubkey = event - .tags - .find(TagKind::custom("P")) - .and_then(|tag| tag.content()) - .and_then(|c| PublicKey::parse(c).ok()) - .context("Cannot parse public key from the event's tags")?; - - Ok(Response::new(event.content.clone(), client_pubkey)) - } - - /// Returns a reference to the nostr client. - pub fn client(&self) -> Client { - self.client.clone() - } - - /// Returns a reference to the event tracker. - pub fn tracker(&self) -> Arc> { - Arc::clone(&self.tracker) - } - - /// Returns a reference to the cache manager. - pub fn gossip(&self) -> Arc> { - Arc::clone(&self.gossip) - } -} diff --git a/crates/state_old/src/storage.rs b/crates/state_old/src/storage.rs deleted file mode 100644 index 16019654..00000000 --- a/crates/state_old/src/storage.rs +++ /dev/null @@ -1,189 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use gpui::SharedString; -use nostr_sdk::prelude::*; - -use crate::NostrRegistry; - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Announcement { - id: EventId, - public_key: PublicKey, - client_name: Option, -} - -impl Announcement { - pub fn new(id: EventId, client_name: Option, public_key: PublicKey) -> Self { - Self { - id, - client_name, - public_key, - } - } - - pub fn id(&self) -> EventId { - self.id - } - - pub fn public_key(&self) -> PublicKey { - self.public_key - } - - pub fn client_name(&self) -> SharedString { - self.client_name - .as_ref() - .map(SharedString::from) - .unwrap_or(SharedString::from("Unknown")) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct Response { - payload: String, - public_key: PublicKey, -} - -impl Response { - pub fn new(payload: String, public_key: PublicKey) -> Self { - Self { - payload, - public_key, - } - } - - pub fn public_key(&self) -> PublicKey { - self.public_key - } - - pub fn payload(&self) -> &str { - self.payload.as_str() - } -} - -#[derive(Debug, Clone, Default)] -pub struct Gossip { - /// Gossip relays for each public key - relays: HashMap)>>, - - /// Messaging relays for each public key - messaging_relays: HashMap>, - - /// Encryption announcement for each public key - announcements: HashMap>, -} - -impl Gossip { - /// Get inbox relays for a public key - pub fn inbox_relays(&self, public_key: &PublicKey) -> Vec { - self.relays - .get(public_key) - .map(|relays| { - relays - .iter() - .filter_map(|(url, metadata)| { - if metadata.is_none() || metadata == &Some(RelayMetadata::Read) { - Some(url.to_owned()) - } else { - None - } - }) - .collect() - }) - .unwrap_or_default() - } - - /// Get outbox relays for a public key - pub fn outbox_relays(&self, public_key: &PublicKey) -> Vec { - self.relays - .get(public_key) - .map(|relays| { - relays - .iter() - .filter_map(|(url, metadata)| { - if metadata.is_none() || metadata == &Some(RelayMetadata::Write) { - Some(url.to_owned()) - } else { - None - } - }) - .collect() - }) - .unwrap_or_default() - } - - /// Insert gossip relays for a public key - pub fn insert_relays(&mut self, event: &Event) { - self.relays.entry(event.pubkey).or_default().extend( - event - .tags - .iter() - .filter_map(|tag| { - if let Some(TagStandard::RelayMetadata { - relay_url, - metadata, - }) = tag.clone().to_standardized() - { - Some((relay_url, metadata)) - } else { - None - } - }) - .take(3), - ); - } - - /// Get messaging relays for a public key - pub fn messaging_relays(&self, public_key: &PublicKey) -> Vec { - self.messaging_relays - .get(public_key) - .cloned() - .unwrap_or_default() - .into_iter() - .collect() - } - - /// Insert messaging relays for a public key - pub fn insert_messaging_relays(&mut self, event: &Event) { - self.messaging_relays - .entry(event.pubkey) - .or_default() - .extend( - event - .tags - .iter() - .filter_map(|tag| { - if let Some(TagStandard::Relay(url)) = tag.as_standardized() { - Some(url.to_owned()) - } else { - None - } - }) - .take(3), - ); - } - - /// Ensure connections for the given relay list - pub async fn ensure_connections(&self, client: &Client, urls: &[RelayUrl]) { - for url in urls { - client.add_relay(url).await.ok(); - client.connect_relay(url).await.ok(); - } - } - - /// Get announcement for a public key - pub fn announcement(&self, public_key: &PublicKey) -> Option { - self.announcements - .get(public_key) - .cloned() - .unwrap_or_default() - } - - /// Insert announcement for a public key - pub fn insert_announcement(&mut self, event: &Event) { - let announcement = NostrRegistry::extract_announcement(event).ok(); - - self.announcements - .entry(event.pubkey) - .or_insert(announcement); - } -} diff --git a/crates/state_old/src/tracker.rs b/crates/state_old/src/tracker.rs deleted file mode 100644 index 739265d0..00000000 --- a/crates/state_old/src/tracker.rs +++ /dev/null @@ -1,43 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::sync::OnceLock; - -use nostr_sdk::prelude::*; - -static INITIALIZED_AT: OnceLock = OnceLock::new(); - -pub fn initialized_at() -> &'static Timestamp { - INITIALIZED_AT.get_or_init(Timestamp::now) -} - -#[derive(Debug, Clone, Default)] -pub struct EventTracker { - /// Tracking events that have been resent by Coop in the current session - pub resent_ids: Vec>, - - /// Temporarily store events that need to be resent later - pub resend_queue: HashMap, - - /// Tracking events sent by Coop in the current session - pub sent_ids: HashSet, - - /// Tracking events seen on which relays in the current session - pub seen_on_relays: HashMap>, -} - -impl EventTracker { - pub fn resent_ids(&self) -> &Vec> { - &self.resent_ids - } - - pub fn resend_queue(&self) -> &HashMap { - &self.resend_queue - } - - pub fn sent_ids(&self) -> &HashSet { - &self.sent_ids - } - - pub fn seen_on_relays(&self) -> &HashMap> { - &self.seen_on_relays - } -} -- 2.54.0 From 7fb9eb993022aac23876ddee919876fd0ffc3e51 Mon Sep 17 00:00:00 2001 From: reya Date: Tue, 6 Jan 2026 10:17:25 +0700 Subject: [PATCH 04/20] wip --- crates/chat/src/lib.rs | 173 ++++++++------- crates/chat_ui/src/lib.rs | 356 +++++++++++-------------------- crates/coop/src/chatspace.rs | 11 +- crates/coop/src/login/mod.rs | 23 +- crates/coop/src/sidebar/mod.rs | 147 ++++++------- crates/coop/src/views/compose.rs | 3 +- 6 files changed, 315 insertions(+), 398 deletions(-) diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 5e96710a..560a2aa9 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -10,7 +10,7 @@ use common::{EventUtils, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT}; use flume::Sender; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; -use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, WeakEntity}; use nostr_sdk::prelude::*; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; @@ -30,6 +30,28 @@ struct GlobalChatRegistry(Entity); impl Global for GlobalChatRegistry {} +/// Chat event. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum ChatEvent { + /// An event to open a room by its ID + OpenRoom(u64), + /// An event to close a room by its ID + CloseRoom(u64), + /// An event to notify UI about a new chat request + Ping, +} + +/// Channel signal. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum NostrEvent { + /// Message received from relay pool + Message(NewMessage), + /// Unwrapping status + Unwrapping(bool), + /// Eose received from relay pool + Eose, +} + /// Chat Registry #[derive(Debug)] pub struct ChatRegistry { @@ -40,26 +62,7 @@ pub struct ChatRegistry { loading: bool, /// Tasks for asynchronous operations - _tasks: SmallVec<[Task<()>; 4]>, -} - -/// Chat event. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub enum ChatEvent { - OpenRoom(u64), - CloseRoom(u64), - NewRequest(RoomKind), -} - -/// Channel signal. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -enum Signal { - /// Message received from relay pool - Message(NewMessage), - /// Loading status of the registry - Loading(bool), - /// Eose received from relay pool - Eose, + _tasks: SmallVec<[Task<()>; 3]>, } impl EventEmitter for ChatRegistry {} @@ -84,7 +87,7 @@ impl ChatRegistry { let status = Arc::new(AtomicBool::new(true)); // Channel for communication between nostr and gpui - let (tx, rx) = flume::bounded::(2048); + let (tx, rx) = flume::bounded::(2048); let mut tasks = smallvec![]; @@ -102,7 +105,7 @@ impl ChatRegistry { tasks.push( // Handle unwrapping progress cx.background_spawn( - async move { Self::handle_unwrapping(&client, &status, &tx).await }, + async move { Self::unwrapping_status(&client, &status, &tx).await }, ), ); @@ -111,19 +114,19 @@ impl ChatRegistry { cx.spawn(async move |this, cx| { while let Ok(message) = rx.recv_async().await { match message { - Signal::Message(message) => { + NostrEvent::Message(message) => { this.update(cx, |this, cx| { this.new_message(message, cx); }) .ok(); } - Signal::Eose => { + NostrEvent::Eose => { this.update(cx, |this, cx| { this.get_rooms(cx); }) .ok(); } - Signal::Loading(status) => { + NostrEvent::Unwrapping(status) => { this.update(cx, |this, cx| { this.set_loading(status, cx); this.get_rooms(cx); @@ -142,7 +145,11 @@ impl ChatRegistry { } } - async fn handle_notifications(client: &Client, loading: &Arc, tx: &Sender) { + async fn handle_notifications( + client: &Client, + loading: &Arc, + tx: &Sender, + ) { let initialized_at = Timestamp::now(); let subscription_id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); @@ -193,7 +200,7 @@ impl ChatRegistry { if !sent_by_coop { let new_message = NewMessage::new(event.id, rumor); - let signal = Signal::Message(new_message); + let signal = NostrEvent::Message(new_message); if let Err(e) = tx.send_async(signal).await { log::error!("Failed to send signal: {}", e); @@ -212,7 +219,7 @@ impl ChatRegistry { } RelayMessage::EndOfStoredEvents(id) => { if id.as_ref() == &subscription_id { - if let Err(e) = tx.send_async(Signal::Eose).await { + if let Err(e) = tx.send_async(NostrEvent::Eose).await { log::error!("Failed to send signal: {}", e); } } @@ -222,7 +229,7 @@ impl ChatRegistry { } } - async fn handle_unwrapping(client: &Client, status: &Arc, tx: &Sender) { + async fn unwrapping_status(client: &Client, status: &Arc, tx: &Sender) { let loop_duration = Duration::from_secs(20); let mut is_start_processing = false; let mut total_loops = 0; @@ -238,7 +245,7 @@ impl ChatRegistry { _ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed); // Send loading signal - if let Err(e) = tx.send_async(Signal::Loading(true)).await { + if let Err(e) = tx.send_async(NostrEvent::Unwrapping(true)).await { log::error!("Failed to send signal: {}", e); } } else { @@ -246,7 +253,7 @@ impl ChatRegistry { // Wait until after 2 loops to prevent exiting early while events are still being processed if is_start_processing && total_loops >= 2 { // Send loading signal - if let Err(e) = tx.send_async(Signal::Loading(false)).await { + if let Err(e) = tx.send_async(NostrEvent::Unwrapping(false)).await { log::error!("Failed to send signal: {}", e); } // Reset the counter @@ -270,12 +277,12 @@ impl ChatRegistry { cx.notify(); } - /// Get a room by its ID. - pub fn room(&self, id: &u64, cx: &App) -> Option> { + /// Get a weak reference to a room by its ID. + pub fn room(&self, id: &u64, cx: &App) -> Option> { self.rooms .iter() - .find(|model| model.read(cx).id == *id) - .cloned() + .find(|this| &this.read(cx).id == id) + .map(|this| this.downgrade()) } /// Get all ongoing rooms. @@ -297,11 +304,30 @@ impl ChatRegistry { } /// Add a new room to the start of list. - pub fn add_room(&mut self, room: Entity, cx: &mut Context) { - self.rooms.insert(0, room); + pub fn add_room(&mut self, room: I, cx: &mut Context) + where + I: Into, + { + self.rooms.insert(0, cx.new(|_| room.into())); cx.notify(); } + /// Emit an open room event. + /// If the room is new, add it to the registry. + pub fn emit_room(&mut self, room: WeakEntity, cx: &mut Context) { + if let Some(room) = room.upgrade() { + let id = room.read(cx).id; + + // If the room is new, add it to the registry. + if !self.rooms.iter().any(|r| r.read(cx).id == id) { + self.rooms.insert(0, room); + } + + // Emit the open room event. + cx.emit(ChatEvent::OpenRoom(id)); + } + } + /// Close a room. pub fn close_room(&mut self, id: u64, cx: &mut Context) { if self.rooms.iter().any(|r| r.read(cx).id == id) { @@ -345,17 +371,6 @@ impl ChatRegistry { cx.notify(); } - /// Push a new room to the chat registry - pub fn push_room(&mut self, room: Entity, cx: &mut Context) { - let id = room.read(cx).id; - - if !self.rooms.iter().any(|r| r.read(cx).id == id) { - self.add_room(room, cx); - } - - cx.emit(ChatEvent::OpenRoom(id)); - } - /// Extend the registry with new rooms. fn extend_rooms(&mut self, rooms: HashSet, cx: &mut Context) { let mut room_map: HashMap = self @@ -506,39 +521,45 @@ impl ChatRegistry { /// If the room doesn't exist, it will be created. /// Updates room ordering based on the most recent messages. pub fn new_message(&mut self, message: NewMessage, cx: &mut Context) { - let id = message.rumor.uniq_id(); - let author = message.rumor.pubkey; let nostr = NostrRegistry::global(cx); + // Get the unique id + let id = message.rumor.uniq_id(); + // Get the author + let author = message.rumor.pubkey; - if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) { - let is_new_event = message.rumor.created_at > room.read(cx).created_at; - let created_at = message.rumor.created_at; + match self.rooms.iter().find(|room| room.read(cx).id == id) { + Some(room) => { + let new_message = message.rumor.created_at > room.read(cx).created_at; + let created_at = message.rumor.created_at; - // Update room - room.update(cx, |this, cx| { - if is_new_event { - this.set_created_at(created_at, cx); + // Update room + room.update(cx, |this, cx| { + // Update the last timestamp if the new message is newer + if new_message { + this.set_created_at(created_at, cx); + } + + // Set this room is ongoing if the new message is from current user + if author == nostr.read(cx).identity().read(cx).public_key() { + this.set_ongoing(cx); + } + + // Emit the new message to the room + this.emit_message(message, cx); + }); + + // Resort all rooms in the registry by their created at (after updated) + if new_message { + self.sort(cx); } - - // Set this room is ongoing if the new message is from current user - if author == nostr.read(cx).identity().read(cx).public_key() { - this.set_ongoing(cx); - } - - // Emit the new message to the room - this.emit_message(message, cx); - }); - - // Resort all rooms in the registry by their created at (after updated) - if is_new_event { - self.sort(cx); } - } else { - // Push the new room to the front of the list - self.add_room(cx.new(|_| Room::from(&message.rumor)), cx); + None => { + // Push the new room to the front of the list + self.add_room(&message.rumor, cx); - // Notify the UI about the new room - cx.emit(ChatEvent::NewRequest(RoomKind::default())); + // Notify the UI about the new room + cx.emit(ChatEvent::Ping); + } } } diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 36f6609a..96058c3e 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -7,10 +7,10 @@ use common::{nip96_upload, RenderedProfile, RenderedTimestamp}; use gpui::prelude::FluentBuilder; use gpui::{ div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext, - ClipboardItem, Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, + ClipboardItem, Context, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString, - StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, Window, + StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window, }; use gpui_tokio::Tokio; use indexset::{BTreeMap, BTreeSet}; @@ -27,7 +27,6 @@ use ui::button::{Button, ButtonVariants}; use ui::context_menu::ContextMenuExt; use ui::dock_area::panel::{Panel, PanelEvent}; use ui::input::{InputEvent, InputState, TextInput}; -use ui::modal::ModalButtonProps; use ui::notification::Notification; use ui::popup_menu::PopupMenuExt; use ui::{ @@ -43,39 +42,52 @@ mod emoji; mod subject; mod text; -pub fn init(room: Entity, window: &mut Window, cx: &mut App) -> Entity { +pub fn init(room: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| ChatPanel::new(room, window, cx)) } +/// Chat Panel pub struct ChatPanel { - // Chat Room - room: Entity, - - // Messages - list_state: ListState, - messages: BTreeSet, - rendered_texts_by_id: BTreeMap, - reports_by_id: BTreeMap>, - - // New Message - input: Entity, - replies_to: Entity>, - - // Media Attachment - attachments: Entity>, - uploading: bool, - - // Panel id: SharedString, focus_handle: FocusHandle, image_cache: Entity, - _subscriptions: SmallVec<[Subscription; 3]>, - _tasks: SmallVec<[Task<()>; 2]>, + /// Chat Room + room: WeakEntity, + + /// Message list state + list_state: ListState, + + /// All messages + messages: BTreeSet, + + /// Mapping message ids to their rendered texts + rendered_texts_by_id: BTreeMap, + + /// Mapping message ids to their reports + reports_by_id: BTreeMap>, + + /// Input state + input: Entity, + + /// Replies to + replies_to: Entity>, + + /// Media Attachment + attachments: Entity>, + + /// Upload state + uploading: bool, + + /// Async operations + tasks: SmallVec<[Task<()>; 2]>, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 2]>, } impl ChatPanel { - pub fn new(room: Entity, window: &mut Window, cx: &mut Context) -> Self { + pub fn new(room: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { let input = cx.new(|cx| { InputState::new(window, cx) .placeholder("Message...") @@ -87,43 +99,63 @@ impl ChatPanel { let attachments = cx.new(|_| vec![]); let replies_to = cx.new(|_| HashSet::new()); - let id = room.read(cx).id.to_string().into(); let messages = BTreeSet::from([Message::system()]); let list_state = ListState::new(messages.len(), ListAlignment::Bottom, px(1024.)); - let connect = room.read(cx).connect(cx); - let get_messages = room.read(cx).get_messages(cx); + let id: SharedString = room + .read_with(cx, |this, _cx| this.id.to_string().into()) + .unwrap_or("Unknown".into()); let mut subscriptions = smallvec![]; let mut tasks = smallvec![]; - tasks.push( - // Get messaging relays and encryption keys announcement for each member - cx.background_spawn(async move { - if let Err(e) = connect.await { - log::error!("Failed to initialize room: {}", e); - } - }), - ); + if let Ok(connect) = room.read_with(cx, |this, cx| this.connect(cx)) { + tasks.push( + // Get messaging relays and encryption keys announcement for each member + cx.background_spawn(async move { + if let Err(e) = connect.await { + log::error!("Failed to initialize room: {}", e); + } + }), + ); + }; - tasks.push( - // Load all messages belonging to this room - cx.spawn_in(window, async move |this, cx| { - let result = get_messages.await; + if let Ok(get_messages) = room.read_with(cx, |this, cx| this.get_messages(cx)) { + tasks.push( + // Load all messages belonging to this room + cx.spawn_in(window, async move |this, cx| { + let result = get_messages.await; - this.update_in(cx, |this, window, cx| { - match result { - Ok(events) => { - this.insert_messages(&events, cx); + this.update_in(cx, |this, window, cx| { + match result { + Ok(events) => { + this.insert_messages(&events, cx); + } + Err(e) => { + window.push_notification(e.to_string(), cx); + } + }; + }) + .ok(); + }), + ); + } + + if let Some(room) = room.upgrade() { + subscriptions.push( + // Subscribe to room events + cx.subscribe_in(&room, window, move |this, _room, event, window, cx| { + match event { + RoomEvent::Incoming(message) => { + this.insert_message(message, false, cx); } - Err(e) => { - window.push_notification(e.to_string(), cx); + RoomEvent::Reload => { + this.load_messages(window, cx); } }; - }) - .ok(); - }), - ); + }), + ); + } subscriptions.push( // Subscribe to input events @@ -138,32 +170,6 @@ impl ChatPanel { ), ); - subscriptions.push( - // Subscribe to room events - cx.subscribe_in(&room, window, move |this, _, signal, window, cx| { - match signal { - RoomEvent::Incoming(message) => { - this.insert_message(message, false, cx); - } - RoomEvent::Reload => { - this.load_messages(window, cx); - } - }; - }), - ); - - subscriptions.push( - // Observe when user close chat panel - cx.on_release_in(window, move |this, window, cx| { - this.messages.clear(); - this.rendered_texts_by_id.clear(); - this.reports_by_id.clear(); - this.image_cache.update(cx, |this, cx| { - this.clear(window, cx); - }); - }), - ); - Self { id, messages, @@ -178,17 +184,14 @@ impl ChatPanel { image_cache: RetainAllImageCache::new(cx), focus_handle: cx.focus_handle(), _subscriptions: subscriptions, - _tasks: tasks, + tasks, } } /// Load all messages belonging to this room fn load_messages(&mut self, window: &mut Window, cx: &mut Context) { - let get_messages = self.room.read(cx).get_messages(cx); - - self._tasks.push( - // Run the task in the background - cx.spawn_in(window, async move |this, cx| { + if let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) { + self.tasks.push(cx.spawn_in(window, async move |this, cx| { let result = get_messages.await; this.update_in(cx, |this, window, cx| { @@ -202,12 +205,13 @@ impl ChatPanel { }; }) .ok(); - }), - ); + })); + } } /// Get user input content and merged all attachments fn input_content(&self, cx: &Context) -> String { + // Get input's value let mut content = self.input.read(cx).value().trim().to_string(); // Get all attaches and merge its with message @@ -241,19 +245,14 @@ impl ChatPanel { return; } - // Temporary disable the message input - self.input.update(cx, |this, cx| { - this.set_loading(false, cx); - this.set_disabled(false, cx); - this.set_value("", window, cx); - }); + // Get the current room entity + let Some(room) = self.room.upgrade().map(|this| this.read(cx)) else { + return; + }; // Get replies_to if it's present let replies: Vec = self.replies_to.read(cx).iter().copied().collect(); - // Get the current room entity - let room = self.room.read(cx); - // Create a temporary message for optimistic update let rumor = room.create_message(&content, replies.as_ref(), cx); let rumor_id = rumor.id.unwrap(); @@ -272,12 +271,14 @@ impl ChatPanel { this.update_in(cx, |this, window, cx| { this.remove_all_replies(cx); this.remove_all_attachments(cx); + // Reset the input to its default state this.input.update(cx, |this, cx| { this.set_loading(false, cx); this.set_disabled(false, cx); this.set_value("", window, cx); }); + // Update the message list this.insert_message(&rumor, true, cx); }) @@ -285,16 +286,15 @@ impl ChatPanel { }) .detach(); - self._tasks.push( - // Continue sending the message in the background - cx.spawn_in(window, async move |this, cx| { - let result = send_message.await; + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let result = send_message.await; - this.update_in(cx, |this, window, cx| { - match result { - Ok(reports) => { - // Update room's status - this.room.update(cx, |this, cx| { + this.update_in(cx, |this, window, cx| { + match result { + Ok(reports) => { + // Update room's status + this.room + .update(cx, |this, cx| { if this.kind != RoomKind::Ongoing { // Update the room kind to ongoing, // but keep the room kind if send failed @@ -303,50 +303,21 @@ impl ChatPanel { cx.notify(); } } - }); + }) + .ok(); - // Insert the sent reports - this.reports_by_id.insert(rumor_id, reports); + // Insert the sent reports + this.reports_by_id.insert(rumor_id, reports); - cx.notify(); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } + cx.notify(); } - }) - .ok(); - }), - ); - } - - /// Resend a failed message - #[allow(dead_code)] - fn resend_message(&mut self, id: &EventId, window: &mut Window, cx: &mut Context) { - if let Some(reports) = self.reports_by_id.get(id).cloned() { - let id_clone = id.to_owned(); - let resend = self.room.read(cx).resend_message(reports, cx); - - cx.spawn_in(window, async move |this, cx| { - let result = resend.await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(reports) => { - this.reports_by_id.entry(id_clone).and_modify(|this| { - *this = reports; - }); - cx.notify(); - } - Err(e) => { - window.push_notification(Notification::error(e.to_string()), cx); - } - }; - }) - .ok(); + Err(e) => { + window.push_notification(e.to_string(), cx); + } + } }) - .detach(); - } + .ok(); + })); } /// Insert a message into the chat panel @@ -379,13 +350,6 @@ impl ChatPanel { } } - /// Insert a warning message into the chat panel - #[allow(dead_code)] - fn insert_warning(&mut self, content: impl Into, cx: &mut Context) { - let m = Message::warning(content.into()); - self.insert_message(m, true, cx); - } - /// Check if a message failed to send by its ID fn is_sent_failed(&self, id: &EventId) -> bool { self.reports_by_id @@ -417,11 +381,6 @@ impl ChatPanel { }) } - fn profile(&self, public_key: &PublicKey, cx: &Context) -> Profile { - let persons = PersonRegistry::global(cx); - persons.read(cx).get_person(public_key, cx) - } - fn scroll_to(&self, id: EventId) { if let Some(ix) = self.messages.iter().position(|m| { if let Message::User(msg) = m { @@ -547,6 +506,11 @@ impl ChatPanel { }); } + fn profile(&self, public_key: &PublicKey, cx: &Context) -> Profile { + let persons = PersonRegistry::global(cx); + persons.read(cx).get_person(public_key, cx) + } + fn render_announcement(&self, ix: usize, cx: &Context) -> AnyElement { v_flex() .id(ix) @@ -1158,61 +1122,6 @@ impl ChatPanel { items } - - fn subject_button(&self, cx: &App) -> Button { - let room = self.room.downgrade(); - let subject = self - .room - .read(cx) - .subject - .as_ref() - .map(|subject| subject.to_string()); - - Button::new("subject") - .icon(IconName::Edit) - .tooltip("Change the subject of the conversation") - .on_click(move |_, window, cx| { - let view = subject::init(subject.clone(), window, cx); - let room = room.clone(); - let weak_view = view.downgrade(); - - window.open_modal(cx, move |this, _window, _cx| { - let room = room.clone(); - let weak_view = weak_view.clone(); - - this.confirm() - .title("Change the subject of the conversation") - .child(view.clone()) - .button_props(ModalButtonProps::default().ok_text("Change")) - .on_ok(move |_, _window, cx| { - if let Ok(subject) = - weak_view.read_with(cx, |this, cx| this.new_subject(cx)) - { - room.update(cx, |this, cx| { - this.set_subject(subject, cx); - }) - .ok(); - } - // true to close the modal - true - }) - }); - }) - } - - fn reload_button(&self, _cx: &App) -> Button { - let room = self.room.downgrade(); - - Button::new("reload") - .icon(IconName::Refresh) - .tooltip("Reload") - .on_click(move |_ev, window, cx| { - _ = room.update(cx, |this, cx| { - this.emit_refresh(cx); - window.push_notification("Reloaded", cx); - }); - }) - } } impl Panel for ChatPanel { @@ -1221,24 +1130,19 @@ impl Panel for ChatPanel { } fn title(&self, cx: &App) -> AnyElement { - self.room.read_with(cx, |this, cx| { - let proxy = AppSettings::get_proxy_user_avatars(cx); - let label = this.display_name(cx); - let url = this.display_image(proxy, cx); + self.room + .read_with(cx, |this, cx| { + let proxy = AppSettings::get_proxy_user_avatars(cx); + let label = this.display_name(cx); + let url = this.display_image(proxy, cx); - h_flex() - .gap_1p5() - .child(Avatar::new(url).size(rems(1.25))) - .child(label) - .into_any() - }) - } - - fn toolbar_buttons(&self, _window: &Window, cx: &App) -> Vec