From 89fd00ddef32e83643bc68df4db3dc7b8805a5aa Mon Sep 17 00:00:00 2001 From: reya Date: Tue, 3 Feb 2026 16:34:47 +0700 Subject: [PATCH] wip: command bar --- Cargo.lock | 2 + crates/chat/src/lib.rs | 77 +------ crates/chat/src/room.rs | 21 +- crates/coop/src/command_bar.rs | 377 ++++++++++++++++++++++++++++----- crates/coop/src/dialogs/mod.rs | 2 - crates/state/Cargo.toml | 2 + crates/state/src/lib.rs | 123 ++++++++++- 7 files changed, 456 insertions(+), 148 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34b11d6..0509fa7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6076,6 +6076,8 @@ dependencies = [ "nostr-sdk", "reqwest", "rustls", + "serde", + "serde_json", "smol", "webbrowser", ] diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index cccdd3c..6a38bd6 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{anyhow, Context as AnyhowContext, Error}; -use common::{EventUtils, BOOTSTRAP_RELAYS}; +use common::EventUtils; use device::DeviceRegistry; use flume::Sender; use fuzzy_matcher::skim::SkimMatcherV2; @@ -16,7 +16,7 @@ use gpui::{ }; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; -use state::{tracker, NostrAddress, NostrRegistry, RelayState, DEVICE_GIFTWRAP, USER_GIFTWRAP}; +use state::{tracker, NostrRegistry, RelayState, DEVICE_GIFTWRAP, USER_GIFTWRAP}; mod message; mod room; @@ -365,56 +365,8 @@ impl ChatRegistry { cx.notify(); } - /// Find for rooms that match the query. - pub fn find(&self, query: &str, cx: &App) -> Task>, Error>> { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let http_client = cx.http_client(); - let query = query.to_string(); - - if let Ok(addr) = Nip05Address::parse(&query) { - let task: Task> = cx.background_spawn(async move { - let profile = addr.profile(&http_client).await?; - let public_key = profile.public_key; - - let opts = SubscribeAutoCloseOptions::default() - .exit_policy(ReqExitPolicy::ExitOnEOSE) - .timeout(Some(Duration::from_secs(3))); - - // Construct the filter for the metadata event - let filter = Filter::new() - .kind(Kind::Metadata) - .author(public_key) - .limit(1); - - // Subscribe to bootstrap relays - client - .subscribe_to(BOOTSTRAP_RELAYS, vec![filter], Some(opts)) - .await?; - - Ok(public_key) - }); - - cx.spawn(async move |cx| { - let public_key = task.await?; - let results = cx.read_global::(|this, cx| { - this.0 - .read_with(cx, |this, cx| this.find_rooms(&public_key.to_hex(), cx)) - }); - Ok(results) - }) - } else { - cx.spawn(async move |cx| { - let results = cx.read_global::(|this, cx| { - this.0.read_with(cx, |this, cx| this.find_rooms(&query, cx)) - }); - Ok(results) - }) - } - } - - /// Internal find function for finding rooms based on a query. - fn find_rooms(&self, query: &str, cx: &App) -> Vec> { + /// Finding rooms based on a query. + pub fn find(&self, query: &str, cx: &App) -> Vec> { let matcher = SkimMatcherV2::default(); if let Ok(public_key) = PublicKey::parse(query) { @@ -436,27 +388,6 @@ impl ChatRegistry { } } - /// Construct a chat room based on NIP-05 address. - pub fn address_to_room(&self, addr: Nip05Address, cx: &App) -> Task> { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let http_client = cx.http_client(); - - cx.background_spawn(async move { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - // Get the profile belonging to the address - let profile = addr.profile(&http_client).await?; - - // Construct the room - let receivers = vec![profile.public_key]; - let room = Room::new(None, public_key, receivers); - - Ok(room) - }) - } - /// Reset the registry. pub fn reset(&mut self, cx: &mut Context) { self.rooms.clear(); diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 192c0d9..e92d4be 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -167,22 +167,11 @@ impl From<&UnsignedEvent> for Room { impl Room { /// Constructs a new room with the given receiver and tags. - pub fn new(subject: Option, author: PublicKey, receivers: Vec) -> Self { - // Convert receiver's public keys into tags - let mut tags: Tags = Tags::from_list( - receivers - .iter() - .map(|pubkey| Tag::public_key(pubkey.to_owned())) - .collect(), - ); - - // Add subject if it is present - if let Some(subject) = subject { - tags.push(Tag::from_standardized_without_cell(TagStandard::Subject( - subject, - ))); - } - + pub fn new(author: PublicKey, receivers: T) -> Self + where + T: IntoIterator, + { + let tags = Tags::from_list(receivers.into_iter().map(Tag::public_key).collect()); let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "") .tags(tags) .build(author); diff --git a/crates/coop/src/command_bar.rs b/crates/coop/src/command_bar.rs index bf66d2b..dbf4532 100644 --- a/crates/coop/src/command_bar.rs +++ b/crates/coop/src/command_bar.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::ops::Range; use std::time::Duration; @@ -6,20 +7,36 @@ use chat::{ChatRegistry, Room}; use common::DebouncedDelay; use gpui::prelude::FluentBuilder; use gpui::{ - anchored, deferred, div, point, px, uniform_list, AppContext, Bounds, Context, Entity, - Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Point, Render, Styled, - Subscription, Task, Window, + anchored, deferred, div, point, px, rems, uniform_list, App, AppContext, Bounds, Context, + Entity, Focusable, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, + Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, + Task, Window, }; use nostr_sdk::prelude::*; +use person::PersonRegistry; +use settings::AppSettings; use smallvec::{smallvec, SmallVec}; use state::{NostrRegistry, FIND_DELAY}; use theme::{ActiveTheme, TITLEBAR_HEIGHT}; +use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::input::{InputEvent, InputState, TextInput}; -use ui::{v_flex, window_paddings, IconName, Sizable, WindowExtension}; +use ui::notification::Notification; +use ui::{h_flex, v_flex, window_paddings, Icon, IconName, Sizable, WindowExtension}; + +const WIDTH: Pixels = px(425.); /// Command bar for searching conversations. pub struct CommandBar { + /// Selected public keys + selected_pkeys: Entity>, + + /// User's contacts + contact_list: Entity>, + + /// Whether to show the contact list + show_contact_list: bool, + /// Find input state find_input: Entity, @@ -30,17 +47,25 @@ pub struct CommandBar { finding: bool, /// Find results - find_results: Entity>>>, + find_results: Entity>>, /// Async find operation find_task: Option>>, + /// Image cache for avatars + image_cache: Entity, + + /// Async tasks + tasks: SmallVec<[Task<()>; 1]>, + /// Event subscriptions _subscriptions: SmallVec<[Subscription; 1]>, } impl CommandBar { pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let selected_pkeys = cx.new(|_| HashSet::new()); + let contact_list = cx.new(|_| vec![]); let find_results = cx.new(|_| None); let find_input = cx.new(|cx| { InputState::new(window, cx) @@ -71,21 +96,68 @@ impl CommandBar { }); } } + InputEvent::Focus => { + this.get_contact_list(window, cx); + } _ => {} }; }), ); Self { + selected_pkeys, + contact_list, + show_contact_list: false, find_debouncer: DebouncedDelay::new(), finding: false, find_input, find_results, find_task: None, + image_cache: RetainAllImageCache::new(cx), + tasks: smallvec![], _subscriptions: subscriptions, } } + fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let task = nostr.read(cx).get_contact_list(cx); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(contacts) => { + this.update(cx, |this, cx| { + this.extend_contacts(contacts, cx); + }) + .ok(); + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification(Notification::error(e.to_string()), cx); + }) + .ok(); + } + }; + })); + } + + /// Extend the contact list with new contacts. + fn extend_contacts(&mut self, contacts: I, cx: &mut Context) + where + I: IntoIterator, + { + self.contact_list.update(cx, |this, cx| { + this.extend(contacts); + cx.notify(); + }); + } + + /// Toggle the visibility of the contact list. + fn toggle_contact_list(&mut self, cx: &mut Context) { + self.show_contact_list = !self.show_contact_list; + cx.notify(); + } + fn debounced_search(&self, window: &mut Window, cx: &mut Context) -> Task<()> { cx.spawn_in(window, async move |this, cx| { this.update_in(cx, |this, window, cx| { @@ -96,15 +168,12 @@ impl CommandBar { } fn search(&mut self, window: &mut Window, cx: &mut Context) { - let chat = ChatRegistry::global(cx); - let query = self.find_input.read(cx).value(); - let nostr = NostrRegistry::global(cx); - let identity = nostr.read(cx).identity().read(cx).public_key(); + let identity = nostr.read(cx).identity(); + let query = self.find_input.read(cx).value(); // Return if the query is empty if query.is_empty() { - log::warn!("Empty query"); return; } @@ -122,36 +191,28 @@ impl CommandBar { // Block the input until the search completes self.set_finding(true, window, cx); - // Perform a local search - let find_rooms = chat.read(cx).find(&query, cx); - // Perform a global search - let find_users = nostr.read(cx).search(&query, cx); + let find_users = if identity.read(cx).owned { + nostr.read(cx).wot_search(&query, cx) + } else { + nostr.read(cx).search(&query, cx) + }; // Run task in the main thread self.find_task = Some(cx.spawn_in(window, async move |this, cx| { - let local_rooms = find_rooms.await.ok().filter(|rooms| !rooms.is_empty()); - let rooms = match local_rooms { - Some(rooms) => rooms, - None => find_users - .await? - .into_iter() - .map(|event| cx.new(|_| Room::new(None, identity, vec![event.pubkey]))) - .collect(), - }; - + let rooms = find_users.await?; + // Update the UI with the search results this.update_in(cx, |this, window, cx| { this.set_results(rooms, cx); this.set_finding(false, window, cx); - }) - .ok(); + })?; Ok(()) })); } - fn set_results(&mut self, rooms: Vec>, cx: &mut Context) { + fn set_results(&mut self, results: Vec, cx: &mut Context) { self.find_results.update(cx, |this, cx| { - *this = Some(rooms); + *this = Some(results); cx.notify(); }); } @@ -182,9 +243,46 @@ impl CommandBar { cx.notify(); } - fn render_list_items(&self, range: Range, cx: &Context) -> Vec { + fn create(&mut self, window: &mut Window, cx: &mut Context) { + let chat = ChatRegistry::global(cx); + let nostr = NostrRegistry::global(cx); + let public_key = nostr.read(cx).identity().read(cx).public_key(); + + let receivers = self.selected(cx); + + chat.update(cx, |this, cx| { + let room = cx.new(|_| Room::new(public_key, receivers)); + this.emit_room(room.downgrade(), cx); + }); + + window.close_modal(cx); + } + + fn select(&mut self, pkey: PublicKey, cx: &mut Context) { + self.selected_pkeys.update(cx, |this, cx| { + if this.contains(&pkey) { + this.remove(&pkey); + } else { + this.insert(pkey); + } + cx.notify(); + }); + } + + fn is_selected(&self, pkey: PublicKey, cx: &App) -> bool { + self.selected_pkeys.read(cx).contains(&pkey) + } + + fn selected(&self, cx: &Context) -> HashSet { + self.selected_pkeys.read(cx).clone() + } + + fn render_results(&self, range: Range, cx: &Context) -> Vec { + let persons = PersonRegistry::global(cx); + let hide_avatar = AppSettings::get_hide_avatar(cx); + let Some(rooms) = self.find_results.read(cx) else { - return vec![div().into_any_element()]; + return vec![]; }; rooms @@ -193,11 +291,106 @@ impl CommandBar { .flatten() .enumerate() .map(|(ix, item)| { - let room = item.read(cx); + let profile = persons.read(cx).get(item, cx); + let pkey = item.to_owned(); + let id = range.start + ix; - div() - .id(range.start + ix) - .child(room.display_name(cx)) + h_flex() + .id(id) + .h_8() + .w_full() + .px_1() + .gap_2() + .rounded(cx.theme().radius) + .when(!hide_avatar, |this| { + this.child( + div() + .flex_shrink_0() + .size_6() + .rounded_full() + .overflow_hidden() + .child(Avatar::new(profile.avatar()).size(rems(1.5))), + ) + }) + .child( + h_flex() + .flex_1() + .justify_between() + .line_clamp(1) + .text_ellipsis() + .truncate() + .text_sm() + .child(profile.name()) + .when(self.is_selected(pkey, cx), |this| { + this.child( + Icon::new(IconName::CheckCircle) + .small() + .text_color(cx.theme().icon_accent), + ) + }), + ) + .hover(|this| this.bg(cx.theme().elevated_surface_background)) + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.select(pkey, cx); + })) + .into_any_element() + }) + .collect() + } + + fn render_contacts(&self, range: Range, cx: &Context) -> Vec { + let persons = PersonRegistry::global(cx); + let hide_avatar = AppSettings::get_hide_avatar(cx); + let contacts = self.contact_list.read(cx); + + contacts + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, item)| { + let profile = persons.read(cx).get(item, cx); + let pkey = item.to_owned(); + let id = range.start + ix; + + h_flex() + .id(id) + .h_8() + .w_full() + .px_1() + .gap_2() + .rounded(cx.theme().radius) + .when(!hide_avatar, |this| { + this.child( + div() + .flex_shrink_0() + .size_6() + .rounded_full() + .overflow_hidden() + .child(Avatar::new(profile.avatar()).size(rems(1.5))), + ) + }) + .child( + h_flex() + .flex_1() + .justify_between() + .line_clamp(1) + .text_ellipsis() + .truncate() + .text_sm() + .child(profile.name()) + .when(self.is_selected(pkey, cx), |this| { + this.child( + Icon::new(IconName::CheckCircle) + .small() + .text_color(cx.theme().icon_accent), + ) + }), + ) + .hover(|this| this.bg(cx.theme().elevated_surface_background)) + .on_click(cx.listener(move |this, _ev, _window, cx| { + this.select(pkey, cx); + })) .into_any_element() }) .collect() @@ -218,26 +411,34 @@ impl Render for CommandBar { size: view_size, }; - let x = bounds.center().x - px(320.) / 2.; + let x = bounds.center().x - WIDTH / 2.; let y = TITLEBAR_HEIGHT; let input_focus_handle = self.find_input.read(cx).focus_handle(cx); let input_focused = input_focus_handle.is_focused(window); let results = self.find_results.read(cx).as_ref(); + let total_results = results.map_or(0, |r| r.len()); + + let contacts = self.contact_list.read(cx); + let button_label = if self.selected_pkeys.read(cx).len() > 1 { + "Create Group DM" + } else { + "Create DM" + }; div() + .image_cache(self.image_cache.clone()) .w_full() .child( TextInput::new(&self.find_input) - .cleanable() .appearance(true) .bordered(false) .xsmall() .text_xs() .when(!self.find_input.read(cx).loading, |this| { this.suffix( - Button::new("find") + Button::new("find-icon") .icon(IconName::Search) .tooltip("Press Enter to search") .transparent() @@ -265,8 +466,9 @@ impl Render for CommandBar { .relative() .left(x) .top(y) - .w(px(320.)) + .w(WIDTH) .min_h_24() + .overflow_y_hidden() .p_1() .gap_1() .justify_between() @@ -275,30 +477,99 @@ impl Render for CommandBar { .bg(cx.theme().surface_background) .shadow_md() .rounded(cx.theme().radius_lg) - .when_some(results, |this, results| { - this.child( - uniform_list( - "rooms", - results.len(), - cx.processor(|this, range, _window, cx| { - this.render_list_items(range, cx) - }), + .map(|this| { + if self.show_contact_list { + this.child( + uniform_list( + "contacts", + contacts.len(), + cx.processor(|this, range, _window, cx| { + this.render_contacts(range, cx) + }), + ) + .when(!contacts.is_empty(), |this| this.h_40()), ) - .h_32(), - ) + .when(contacts.is_empty(), |this| { + this.child( + h_flex() + .h_10() + .w_full() + .items_center() + .justify_center() + .text_center() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from( + "Your contact list is empty", + )), + ) + }) + } else { + this.child( + uniform_list( + "rooms", + total_results, + cx.processor(|this, range, _window, cx| { + this.render_results(range, cx) + }), + ) + .when(total_results > 0, |this| this.h_40()), + ) + .when(total_results == 0, |this| { + this.child( + h_flex() + .h_10() + .w_full() + .items_center() + .justify_center() + .text_center() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from( + "Search results appear here", + )), + ) + }) + } }) .child( - v_flex() + h_flex() .pt_1() .border_t_1() .border_color(cx.theme().border_variant) + .justify_end() .child( - Button::new("directory") - .icon(IconName::Door) - .label("Nostr Directory") + Button::new("show-contacts") + .label({ + if self.show_contact_list { + "Hide contact list" + } else { + "Show contact list" + } + }) .ghost() .xsmall() - .no_center(), + .on_click(cx.listener( + move |this, _ev, _window, cx| { + this.toggle_contact_list(cx); + }, + )), + ) + .when( + !self.selected_pkeys.read(cx).is_empty(), + |this| { + this.child( + Button::new("create") + .label(button_label) + .primary() + .xsmall() + .on_click(cx.listener( + move |this, _ev, window, cx| { + this.create(window, cx); + }, + )), + ) + }, ), ), ), diff --git a/crates/coop/src/dialogs/mod.rs b/crates/coop/src/dialogs/mod.rs index ff57b85..7e8b4b2 100644 --- a/crates/coop/src/dialogs/mod.rs +++ b/crates/coop/src/dialogs/mod.rs @@ -1,3 +1 @@ -pub mod compose; -pub mod profile; pub mod screening; diff --git a/crates/state/Cargo.toml b/crates/state/Cargo.toml index cda5d26..d6af627 100644 --- a/crates/state/Cargo.toml +++ b/crates/state/Cargo.toml @@ -19,5 +19,7 @@ flume.workspace = true log.workspace = true anyhow.workspace = true webbrowser.workspace = true +serde.workspace = true +serde_json.workspace = true rustls = "0.23" diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 8cac077..63f85b5 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -3,7 +3,7 @@ use std::os::unix::fs::PermissionsExt; use std::time::Duration; use anyhow::{anyhow, Error}; -use common::{config_dir, BOOTSTRAP_RELAYS, CLIENT_NAME, SEARCH_RELAYS}; +use common::{config_dir, CLIENT_NAME}; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; use nostr_connect::prelude::*; use nostr_lmdb::NostrLmdb; @@ -37,6 +37,17 @@ pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app"; pub const DEVICE_GIFTWRAP: &str = "device-gift-wraps"; /// Default subscription id for user gift wrap events pub const USER_GIFTWRAP: &str = "user-gift-wraps"; +/// Default vertex relays +pub const WOT_RELAYS: [&str; 1] = ["wss://relay.vertexlab.io"]; +/// Default search relays +pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.noswhere.com"]; +/// Default bootstrap relays +pub const BOOTSTRAP_RELAYS: [&str; 4] = [ + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://relay.nos.social", + "wss://user.kindpag.es", +]; pub fn init(cx: &mut App) { // Initialize the tokio runtime @@ -214,6 +225,11 @@ impl NostrRegistry { client.add_relay(url).await?; } + // Add wot relay to the relay pool + for url in WOT_RELAYS.into_iter() { + client.add_relay(url).await?; + } + // Connect to all added relays client.connect().await; @@ -647,6 +663,19 @@ impl NostrRegistry { })); } + /// Get contact list for the current user + pub fn get_contact_list(&self, cx: &App) -> Task, Error>> { + let client = self.client(); + let public_key = self.identity().read(cx).public_key(); + + cx.background_spawn(async move { + let contacts = client.database().contacts_public_keys(public_key).await?; + let results = contacts.into_iter().collect(); + + Ok(results) + }) + } + /// Set the metadata for the current user pub fn set_metadata(&self, metadata: &Metadata, cx: &App) -> Task> { let client = self.client(); @@ -806,13 +835,41 @@ impl NostrRegistry { (signer, uri) } + /// Get the public key of a NIP-05 address + pub fn get_address(&self, addr: Nip05Address, cx: &App) -> Task> { + let client = self.client(); + let http_client = cx.http_client(); + + cx.background_spawn(async move { + let profile = addr.profile(&http_client).await?; + let public_key = profile.public_key; + + let opts = SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE) + .timeout(Some(Duration::from_secs(3))); + + // Construct the filter for the metadata event + let filter = Filter::new() + .kind(Kind::Metadata) + .author(public_key) + .limit(1); + + // Subscribe to bootstrap relays + client + .subscribe_to(BOOTSTRAP_RELAYS, vec![filter], Some(opts)) + .await?; + + Ok(public_key) + }) + } + /// Perform a NIP-50 global search for user profiles based on a given query - pub fn search(&self, query: &str, cx: &App) -> Task, Error>> { + pub fn search(&self, query: &str, cx: &App) -> Task, Error>> { let client = self.client(); let query = query.to_string(); cx.background_spawn(async move { - let mut results: Vec = Vec::with_capacity(FIND_LIMIT); + let mut results: Vec = Vec::with_capacity(FIND_LIMIT); // Construct the filter for the search query let filter = Filter::new() @@ -828,7 +885,7 @@ impl NostrRegistry { // Collect the results while let Some((_url, res)) = stream.next().await { if let Ok(event) = res { - results.push(event); + results.push(event.pubkey); } } @@ -839,6 +896,64 @@ impl NostrRegistry { Ok(results) }) } + + /// Perform a WoT (via Vertex) search for a given query. + pub fn wot_search(&self, query: &str, cx: &App) -> Task, Error>> { + let client = self.client(); + let query = query.to_string(); + + cx.background_spawn(async move { + let signer = client.signer().await?; + + // Construct a vertex request event + let event = EventBuilder::new(Kind::Custom(5315), "") + .tags(vec![ + Tag::custom(TagKind::custom("param"), vec!["search", &query]), + Tag::custom(TagKind::custom("param"), vec!["limit", "10"]), + ]) + .sign(&signer) + .await?; + + // Send the event to vertex relays + let output = client.send_event_to(WOT_RELAYS, &event).await?; + + // Construct a filter to get the response or error from vertex + let filter = Filter::new() + .kinds(vec![Kind::Custom(6315), Kind::Custom(7000)]) + .event(output.id().to_owned()); + + // Stream events from the search relays + let mut stream = client + .stream_events_from(WOT_RELAYS, vec![filter], Duration::from_secs(3)) + .await?; + + while let Some((_url, res)) = stream.next().await { + if let Ok(event) = res { + match event.kind { + Kind::Custom(6315) => { + let content: serde_json::Value = serde_json::from_str(&event.content)?; + let pubkeys: Vec = content + .as_array() + .into_iter() + .flatten() + .filter_map(|item| item.as_object()) + .filter_map(|obj| obj.get("pubkey").and_then(|v| v.as_str())) + .filter_map(|pubkey_str| PublicKey::parse(pubkey_str).ok()) + .collect(); + + return Ok(pubkeys); + } + Kind::Custom(7000) => { + return Err(anyhow!("Search error")); + } + _ => {} + } + } + } + + Err(anyhow!("No results for query: {query}")) + }) + } } #[derive(Debug, Clone)]