From c058270eff7d93a3b4871905c438ac7ae8d4f42f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 7 Oct 2026 08:11:57 +0700 Subject: [PATCH 01/10] add contact-based community suggestions --- crates/nip29/src/lib.rs | 537 ++++++++++++++++++++++++++ crates/state/src/lib.rs | 31 +- crates/workspace/src/panels/browse.rs | 133 ++++++- 3 files changed, 674 insertions(+), 27 deletions(-) diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index 72de7e53..51286642 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -20,8 +20,10 @@ pub(crate) static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); const LIST_SUBSCRIPTION: &str = "nip29-list"; const FORK_SUBSCRIPTION: &str = "nip29-forks"; +const CONTACTS_SUBSCRIPTION: &str = "nip29-contacts"; const GROUP_PREFIX: &str = "grp-"; const BROWSE_PREFIX: &str = "brw-"; +const PREVIEW_PREFIX: &str = "prv-"; const PAGE_PREFIX: &str = "pg-"; const BROWSE_LIMIT: usize = 500; const BROWSE_TIMEOUT: Duration = Duration::from_secs(10); @@ -41,6 +43,8 @@ pub enum GroupsEvent { Error(String), Browsed(Box), BrowseSettled(RelayUrl), + Suggested(Box), + SuggestSettled, } #[derive(Debug)] @@ -49,6 +53,8 @@ enum Signal { Eose(SubscriptionId), Closed(SubscriptionId, String), BrowseSettled(SubscriptionId), + ContactListsSettled, + ContactPreviewSettled(SubscriptionId), Forks, } @@ -60,6 +66,10 @@ fn fork_subscription_id() -> SubscriptionId { SubscriptionId::new(FORK_SUBSCRIPTION) } +fn contacts_subscription_id() -> SubscriptionId { + SubscriptionId::new(CONTACTS_SUBSCRIPTION) +} + fn subscription_id(key: &GroupKey) -> SubscriptionId { SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32)) } @@ -71,6 +81,13 @@ fn browse_subscription(relay: &RelayUrl) -> SubscriptionId { SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32)) } +fn preview_subscription(relay: &RelayUrl) -> SubscriptionId { + let mut hasher = DefaultHasher::new(); + relay.hash(&mut hasher); + + SubscriptionId::new(format!("{PREVIEW_PREFIX}{:08x}", hasher.finish() as u32)) +} + fn page_subscription(key: &GroupKey) -> SubscriptionId { SubscriptionId::new(format!("{PAGE_PREFIX}{:08x}", key.uniq_id() as u32)) } @@ -83,8 +100,10 @@ fn is_own_subscription(id: &SubscriptionId) -> bool { let id = id.as_str(); id == LIST_SUBSCRIPTION || id == FORK_SUBSCRIPTION + || id == CONTACTS_SUBSCRIPTION || id.starts_with(GROUP_PREFIX) || id.starts_with(BROWSE_PREFIX) + || id.starts_with(PREVIEW_PREFIX) || id.starts_with(PAGE_PREFIX) } @@ -98,6 +117,16 @@ pub struct GroupsRegistry { observers: HashMap, forks: HashMap, fork_authors: HashSet, + /// The follow-list authors the contacts subscription covers. + contact_authors: HashSet, + /// The (relay, group) references of the contacts, minus the joined groups. + contact_refs: HashMap>, + /// The contact groups whose metadata has resolved so far. + suggestions: Vec, + /// The in-flight preview subscriptions, keyed by subscription id. + previews: HashMap, + /// Whether the contacts' group lists have been answered. + contact_settled: bool, signal_tx: flume::Sender, signal_rx: flume::Receiver, notification_listener: Option>, @@ -152,6 +181,11 @@ impl GroupsRegistry { observers: HashMap::new(), forks: HashMap::new(), fork_authors: HashSet::new(), + contact_authors: HashSet::new(), + contact_refs: HashMap::new(), + suggestions: Vec::new(), + previews: HashMap::new(), + contact_settled: false, signal_tx: tx, signal_rx: rx, notification_listener: None, @@ -280,6 +314,465 @@ impl GroupsRegistry { cx.emit(GroupsEvent::BrowseSettled(relay)); } + pub fn load_contacts_groups(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + + if nostr.read(cx).current_user().is_none() { + return; + } + + let task: Task, Error>> = cx.background_spawn(async move { + let me = signer.get_public_key_async().await?; + let filter = Filter::new().author(me).kind(Kind::ContactList).limit(1); + + let contacts = client + .database() + .query(filter) + .await? + .into_iter() + .next() + .map(|event| event.tags.public_keys().collect()) + .unwrap_or_default(); + + Ok(contacts) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let authors = match task.await { + Ok(authors) => authors, + Err(error) => { + log::warn!("nip29: loading the contact list failed: {error}"); + return; + } + }; + + if authors.is_empty() { + if let Err(error) = this.update(cx, |this, cx| { + // Nobody is followed, so there is nothing to discover. + this.contact_settled = true; + cx.emit(GroupsEvent::SuggestSettled); + cx.notify(); + }) { + log::warn!("nip29: settling the contacts failed: {error}"); + } + return; + } + + // Show the cached lists before the relays answer. + let lists = match this.update(cx, |this, cx| this.start_contacts_hydration(authors, cx)) + { + Ok(Some(task)) => match task.await { + Ok(lists) => lists, + Err(error) => { + log::warn!("nip29: loading the cached group lists failed: {error}"); + Vec::new() + } + }, + Ok(None) => return, + Err(error) => { + log::warn!("nip29: hydrating the contacts failed: {error}"); + return; + } + }; + + let refs = match this.update(cx, |this, cx| { + let newest: HashMap = + lists.into_iter().fold(HashMap::new(), |mut newest, event| { + match newest.get(&event.pubkey) { + Some(current) if current.created_at >= event.created_at => newest, + _ => { + newest.insert(event.pubkey, event); + newest + } + } + }); + + for event in newest.into_values() { + this.absorb_contact_list(&event, cx); + } + + this.contact_refs.clone() + }) { + Ok(refs) => refs, + Err(error) => { + log::warn!("nip29: applying the cached group lists failed: {error}"); + return; + } + }; + + // Show the cached metadata before the relays answer. + let previews = match this.update(cx, |this, cx| this.load_cached_previews(refs, cx)) { + Ok(task) => match task.await { + Ok(previews) => previews, + Err(error) => { + log::warn!("nip29: loading the cached group metadata failed: {error}"); + Vec::new() + } + }, + Err(error) => { + log::warn!("nip29: hydrating the contacts failed: {error}"); + return; + } + }; + + if let Err(error) = this.update(cx, |this, cx| { + this.absorb_cached_previews(previews, cx); + this.subscribe_contacts(cx); + }) { + log::warn!("nip29: applying the cached group metadata failed: {error}"); + } + })); + } + + /// The contact groups whose metadata has resolved so far. + pub fn contacts_candidates(&self) -> &[GroupCandidate] { + &self.suggestions + } + + /// Whether the contact lists or any of their previews are still on the way. + pub fn contacts_loading(&self) -> bool { + !self.contact_settled || !self.previews.is_empty() + } + + /// Reset the contacts state and start loading the cached group lists. + fn start_contacts_hydration( + &mut self, + authors: HashSet, + cx: &mut Context, + ) -> Option, Error>>> { + if authors == self.contact_authors { + return None; + } + + self.contact_authors = authors; + self.contact_refs.clear(); + self.suggestions.clear(); + self.previews.clear(); + self.contact_settled = false; + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let filter = Filter::new() + .authors(self.contact_authors.iter().copied()) + .kind(Kind::SimpleGroups); + + let task: Task, Error>> = cx.background_spawn(async move { + Ok(client.database().query(filter).await?.into_iter().collect()) + }); + + cx.notify(); + Some(task) + } + + /// Load the cached metadata for the referenced groups from the database. + fn load_cached_previews( + &self, + refs: HashMap>, + cx: &mut Context, + ) -> Task, Error>> { + if refs.is_empty() { + return Task::ready(Ok(Vec::new())); + } + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let ids: HashSet<&str> = refs.values().flatten().map(GroupId::as_str).collect(); + let filter = Filter::new().kind(Kind::GroupMetadata).identifiers(ids); + + cx.background_spawn(async move { + Ok(client.database().query(filter).await?.into_iter().collect()) + }) + } + + /// Seed the suggestions from the cached group metadata. + fn absorb_cached_previews(&mut self, previews: Vec, cx: &mut Context) { + // Keep the newest metadata per group, the database may still hold + // superseded versions. + let newest: HashMap = + previews + .into_iter() + .fold(HashMap::new(), |mut newest, event| { + let metadata = match GroupMetadata::parse(&event) { + Ok(metadata) => metadata, + Err(error) => { + log::warn!("nip29: ignoring malformed cached group metadata: {error}"); + return newest; + } + }; + + let Some(id) = metadata.id().cloned() else { + return newest; + }; + + match newest.get(&id) { + Some((created_at, _)) if *created_at >= event.created_at => newest, + _ => { + newest.insert(id, (event.created_at, metadata)); + newest + } + } + }); + + for (relay, ids) in self.contact_refs.clone() { + for id in ids { + let Some((_, metadata)) = newest.get(&id) else { + continue; + }; + + // Hidden groups are not discoverable (NIP-29). + if metadata.hidden { + continue; + } + + self.upsert_suggestion( + GroupCandidate { + key: GroupKey::new(relay.clone(), id.clone()), + metadata: metadata.clone(), + }, + cx, + ); + } + } + } + + /// Subscribe to the contacts' public group lists. + fn subscribe_contacts(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let filter = Filter::new() + .authors(self.contact_authors.iter().copied()) + .kind(Kind::SimpleGroups); + + let opts = SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE) + .timeout(Some(BROWSE_TIMEOUT)); + + let tx = self.signal_tx.clone(); + + self.tasks.push(cx.background_spawn(async move { + let refused = match client + .subscribe(ReqTarget::auto([filter])) + .with_id(contacts_subscription_id()) + .close_on(opts) + .await + { + Ok(output) => output.success.is_empty(), + Err(error) => { + log::warn!("subscribing to the contacts group lists failed: {error}"); + true + } + }; + + // When every relay refused the request no EOSE will arrive. + if refused && let Err(error) = tx.send_async(Signal::ContactListsSettled).await { + log::warn!("the contacts settle signal was dropped: {error}"); + } + })); + + cx.notify(); + } + + /// Ask every relay hosting referenced groups for the groups' metadata. + fn sync_contact_previews(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + for (relay, ids) in self.contact_refs.clone() { + // A subscription is already asking this relay for previews. + if self.previews.values().any(|in_flight| in_flight == &relay) { + continue; + } + + let id = preview_subscription(&relay); + self.previews.insert(id.clone(), relay.clone()); + let client = client.clone(); + + let filter = Filter::new() + .kind(Kind::GroupMetadata) + .identifiers(ids.iter().map(GroupId::as_str)); + + let opts = SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE) + .timeout(Some(BROWSE_TIMEOUT)); + + let tx = self.signal_tx.clone(); + + self.tasks.push(cx.background_spawn(async move { + client.add_relay(relay.clone()).and_connect().await.ok(); + + let refused = match client + .subscribe(ReqTarget::single(relay.clone(), [filter])) + .with_id(id.clone()) + .close_on(opts) + .await + { + Ok(output) => output.success.is_empty(), + Err(error) => { + log::warn!("fetching group previews from {relay} failed: {error}"); + true + } + }; + + // When the relay refused the request no EOSE will arrive. + if refused + && let Err(error) = tx.send_async(Signal::ContactPreviewSettled(id)).await + { + log::warn!("the preview settle signal was dropped: {error}"); + } + })); + } + + cx.notify(); + } + + fn absorb_contact_list(&mut self, event: &Event, cx: &mut Context) { + let Ok(list) = GroupList::parse(event) else { + log::warn!( + "nip29: ignoring a malformed group list from {}", + event.pubkey + ); + return; + }; + + let joined: HashSet<(RelayUrl, GroupId)> = self + .groups + .iter() + .map(|group| { + let key = group.read(cx).key(); + (key.relay().clone(), key.id().clone()) + }) + .collect(); + + let mut added = false; + + for entry in list.entries() { + if joined.contains(&(entry.relay.clone(), entry.id.clone())) { + continue; + } + + if self + .contact_refs + .entry(entry.relay.clone()) + .or_default() + .insert(entry.id.clone()) + { + added = true; + } + } + + if !added { + return; + } + + // Lists may still arrive after the first EOSE, + // so previews sync as the references grow as well. + if self.contact_settled { + self.sync_contact_previews(cx); + } + + cx.notify(); + } + + fn absorb_contact_group(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context) { + let Some(relay) = self.previews.get(id).cloned() else { + return; + }; + + let metadata = match GroupMetadata::parse(event) { + Ok(metadata) => metadata, + Err(error) => { + log::warn!("nip29: ignoring malformed group metadata from {relay}: {error}"); + return; + } + }; + + // Hidden groups are not discoverable (NIP-29). + if metadata.hidden { + return; + } + + // A relay may answer with more than the filter asked for. + let Some(group) = metadata.id().cloned() else { + return; + }; + + if !self + .contact_refs + .get(&relay) + .is_some_and(|ids| ids.contains(&group)) + { + return; + } + + self.upsert_suggestion( + GroupCandidate { + key: GroupKey::new(relay, group), + metadata, + }, + cx, + ); + } + + fn upsert_suggestion(&mut self, candidate: GroupCandidate, cx: &mut Context) { + match self.suggestions.iter_mut().find(|s| s.key == candidate.key) { + Some(existing) => *existing = candidate.clone(), + None => self.suggestions.push(candidate.clone()), + } + + self.suggestions.sort_by(|a, b| { + a.display_name() + .to_lowercase() + .cmp(&b.display_name().to_lowercase()) + }); + + cx.emit(GroupsEvent::Suggested(Box::new(candidate))); + cx.notify(); + } + + /// Drop suggestions whose group is no longer referenced by any contact. + fn prune_suggestions(&mut self, cx: &mut Context) { + let before = self.suggestions.len(); + + self.suggestions.retain(|suggestion| { + self.contact_refs + .get(suggestion.key.relay()) + .is_some_and(|ids| ids.contains(suggestion.key.id())) + }); + + if self.suggestions.len() != before { + cx.notify(); + } + } + + /// The contacts' group lists have been answered: resolve the metadata. + fn handle_contact_lists_settled(&mut self, cx: &mut Context) { + self.contact_settled = true; + self.sync_contact_previews(cx); + + if self.previews.is_empty() { + self.prune_suggestions(cx); + cx.emit(GroupsEvent::SuggestSettled); + cx.notify(); + } + } + + fn handle_contact_preview_settled(&mut self, id: &SubscriptionId, cx: &mut Context) { + if self.previews.remove(id).is_none() { + return; + } + + if self.previews.is_empty() { + self.prune_suggestions(cx); + cx.emit(GroupsEvent::SuggestSettled); + cx.notify(); + } + } + pub fn load_more(&mut self, key: &GroupKey, cx: &mut Context) { let Some(group) = self.group(key, cx) else { return; @@ -337,8 +830,14 @@ impl GroupsRegistry { ids.push(list_subscription_id()); ids.push(fork_subscription_id()); ids.extend(self.browse.keys().cloned()); + ids.extend(self.previews.keys().cloned()); self.browse.clear(); + self.previews.clear(); + self.contact_authors.clear(); + self.contact_refs.clear(); + self.suggestions.clear(); + self.contact_settled = false; self.tasks.push(cx.background_spawn(async move { for id in ids { @@ -817,11 +1316,21 @@ impl GroupsRegistry { return; } + if id == contacts_subscription_id() { + self.absorb_contact_list(&event, cx); + return; + } + if self.browse.contains_key(&id) { self.absorb_candidate(&id, &event, cx); return; } + if self.previews.contains_key(&id) { + self.absorb_contact_group(&id, &event, cx); + return; + } + let Some(key) = self.routes.get(&id).cloned() else { return; }; @@ -858,6 +1367,16 @@ impl GroupsRegistry { } fn handle_eose(&mut self, id: SubscriptionId, cx: &mut Context) { + if id == contacts_subscription_id() { + self.handle_contact_lists_settled(cx); + return; + } + + if self.previews.contains_key(&id) { + self.handle_contact_preview_settled(&id, cx); + return; + } + let Some(key) = self.routes.get(&id).cloned() else { return; }; @@ -886,6 +1405,18 @@ impl GroupsRegistry { } fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context) { + if id == contacts_subscription_id() { + log::warn!("nip29: the contacts' group lists were refused: {reason}"); + self.handle_contact_lists_settled(cx); + return; + } + + if let Some(relay) = self.previews.get(&id).cloned() { + log::warn!("nip29: fetching previews from {relay} was refused: {reason}"); + self.handle_contact_preview_settled(&id, cx); + return; + } + if let Some(relay) = self.browse.get(&id) { log::warn!("nip29: browsing {relay} was refused: {reason}"); return; @@ -1000,6 +1531,12 @@ impl GroupsRegistry { Signal::BrowseSettled(id) => { this.settle_browse(id, cx); } + Signal::ContactListsSettled => { + this.handle_contact_lists_settled(cx); + } + Signal::ContactPreviewSettled(id) => { + this.handle_contact_preview_settled(&id, cx); + } Signal::Forks => { this.sync_forks(true, cx); } diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 3499ae37..34e25d04 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -30,9 +30,6 @@ pub use nip05::*; pub use signer::{CoopAuthUrlHandler, UniversalSigner}; pub fn init(cx: &mut App, cli_key: Option) { - // 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`. #[cfg(not(target_arch = "wasm32"))] rustls::crypto::aws_lc_rs::default_provider() .install_default() @@ -136,22 +133,20 @@ impl NostrRegistry { // Connect to bootstrap relays once the registry has been returned to the app cx.defer(move |cx| { - entity - .update(cx, |this, cx| { - this.connect_bootstrap_relays(cx); + let _ = entity.update(cx, |this, cx| { + this.connect_bootstrap_relays(cx); - if cfg!(target_arch = "wasm32") { - this.mark_ready(cx); - cx.emit(StateEvent::NoSigner); - } else if let Some(secret) = cli_key { - // Use CLI-provided key -- same path as get_user_credential - let keys = Keys::new(secret); - this.set_signer(keys, cx); - } else { - this.get_user_credential(cx); - } - }) - .ok(); + if cfg!(target_arch = "wasm32") { + this.mark_ready(cx); + cx.emit(StateEvent::NoSigner); + } else if let Some(secret) = cli_key { + // Use CLI-provided key -- same path as get_user_credential + let keys = Keys::new(secret); + this.set_signer(keys, cx); + } else { + this.get_user_credential(cx); + } + }); }); Self { diff --git a/crates/workspace/src/panels/browse.rs b/crates/workspace/src/panels/browse.rs index ce39a236..f38e2109 100644 --- a/crates/workspace/src/panels/browse.rs +++ b/crates/workspace/src/panels/browse.rs @@ -35,6 +35,10 @@ pub struct BrowsePanel { relay: Option, /// The groups the relay has announced so far candidates: Vec, + /// The communities of the user's contacts + contacts: Vec, + /// Whether the contacts' communities are still resolving + contacts_loading: bool, /// Whether the relay is still answering loading: bool, _subscriptions: SmallVec<[Subscription; 2]>, @@ -63,21 +67,71 @@ impl BrowsePanel { GroupsEvent::BrowseSettled(relay) => { this.settle(relay.clone(), cx); } + GroupsEvent::Suggested(candidate) => { + this.push_contact(candidate.as_ref().clone(), cx); + } + GroupsEvent::SuggestSettled => { + this.settle_contacts(cx); + } _ => {} }), ); + // Wait for the panel to leave the stack before kicking off the sync. + cx.defer_in(window, |this, _window, cx| { + this.load_contacts(cx); + }); + Self { focus_handle: cx.focus_handle(), dock, input, relay: None, candidates: Vec::new(), + contacts: Vec::new(), + contacts_loading: true, loading: false, _subscriptions: subscriptions, } } + /// Pull the resolved contacts' communities and start the contacts sync. + fn load_contacts(&mut self, cx: &mut Context) { + let registry = GroupsRegistry::global(cx); + + let (candidates, loading) = registry.update(cx, |registry, cx| { + registry.load_contacts_groups(cx); + ( + registry.contacts_candidates().to_vec(), + registry.contacts_loading(), + ) + }); + + self.contacts = candidates; + self.contacts_loading = loading; + cx.notify(); + } + + fn push_contact(&mut self, candidate: GroupCandidate, cx: &mut Context) { + match self.contacts.iter_mut().find(|c| c.key == candidate.key) { + Some(existing) => *existing = candidate, + None => self.contacts.push(candidate), + } + + self.contacts.sort_by(|a, b| { + a.display_name() + .to_lowercase() + .cmp(&b.display_name().to_lowercase()) + }); + + cx.notify(); + } + + fn settle_contacts(&mut self, cx: &mut Context) { + self.contacts_loading = false; + cx.notify(); + } + /// Browse the relay in the input. fn browse(&mut self, window: &mut Window, cx: &mut Context) { let registry = GroupsRegistry::global(cx); @@ -150,7 +204,13 @@ impl BrowsePanel { ); } - fn render_rows(&self, range: Range, cx: &mut Context) -> Vec { + fn render_rows( + &self, + candidates: &[GroupCandidate], + relay_detail: bool, + range: Range, + cx: &mut Context, + ) -> Vec { let hide_avatar = AppSettings::get_hide_avatar(cx); let panel = cx.entity().downgrade(); @@ -162,7 +222,7 @@ impl BrowsePanel { .map(|group| group.read(cx).key().clone()) .collect(); - self.candidates + candidates .get(range) .into_iter() .flatten() @@ -173,6 +233,11 @@ impl BrowsePanel { .display_image() .map(|picture| SharedString::from(picture.to_owned())); let seed = key.cache_tag(); + let detail = if relay_detail { + key.relay().domain().unwrap_or_else(|| key.relay().as_str()) + } else { + key.id().as_str() + }; let is_joined = joined.contains(&key); let action = if is_joined { @@ -221,7 +286,7 @@ impl BrowsePanel { .text_xs() .text_color(cx.theme().text_placeholder) .truncate() - .child(SharedString::from(key.id().as_str().to_owned())), + .child(SharedString::from(detail.to_owned())), ), ) .child(action) @@ -270,14 +335,53 @@ impl Focusable for BrowsePanel { impl Render for BrowsePanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let count = self.candidates.len(); + let from_contacts = self.relay.is_none(); - let body = if self.relay.is_none() { - notice("Enter a relay to browse its groups.", cx) + let (contacts, contact_count) = if from_contacts { + let registry = GroupsRegistry::global(cx); + let joined: Vec = registry + .read(cx) + .groups() + .iter() + .map(|group| group.read(cx).key().clone()) + .collect(); + + let contacts: Vec = self + .contacts + .iter() + .filter(|candidate| !joined.contains(&candidate.key)) + .cloned() + .collect(); + + let count = contacts.len(); + (contacts, count) + } else { + (Vec::new(), 0) + }; + + let body = if from_contacts { + if contact_count > 0 { + uniform_list( + "browse-contacts", + contact_count, + cx.processor(move |this, range, _window, cx| { + this.render_rows(&contacts, true, range, cx) + }), + ) + .h_full() + .into_any_element() + } else if self.contacts_loading { + notice("Loading communities from your contacts…", cx) + } else { + notice("No communities from your contacts.", cx) + } } else if count > 0 { uniform_list( "browse-groups", count, - cx.processor(|this, range, _window, cx| this.render_rows(range, cx)), + cx.processor(|this, range, _window, cx| { + this.render_rows(&this.candidates, false, range, cx) + }), ) .h_full() .into_any_element() @@ -303,10 +407,21 @@ impl Render for BrowsePanel { this.browse(window, cx); })), ) - .when(self.loading, |this| { - this.child(Indicator::new().small().color(cx.theme().icon_accent)) - }), + .when( + self.loading || (from_contacts && self.contacts_loading), + |this| this.child(Indicator::new().small().color(cx.theme().icon_accent)), + ), ) + .when(from_contacts && contact_count > 0, |this| { + this.child( + div() + .px_3() + .pb_1() + .text_xs() + .text_color(cx.theme().text_muted) + .child("From your contacts"), + ) + }) .child(div().flex_1().min_h_0().child(body)) } } -- 2.54.0 From 96ceacb5fb3e13e37a9c67eadfcb9d8256baf626 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 7 Oct 2026 09:10:25 +0700 Subject: [PATCH 02/10] extract group discovery into a discovery entity --- crates/nip29/src/discovery.rs | 158 +++++++++ crates/nip29/src/lib.rs | 472 +++++++++++++------------- crates/workspace/src/panels/browse.rs | 122 +++---- 3 files changed, 429 insertions(+), 323 deletions(-) create mode 100644 crates/nip29/src/discovery.rs diff --git a/crates/nip29/src/discovery.rs b/crates/nip29/src/discovery.rs new file mode 100644 index 00000000..353f56b7 --- /dev/null +++ b/crates/nip29/src/discovery.rs @@ -0,0 +1,158 @@ +use std::collections::{HashMap, HashSet}; + +use gpui::Context; +use nostr_sdk::prelude::*; + +use crate::protocol::{GroupCandidate, GroupId, GroupList}; + +/// The groups discovered from the cache and the relays. +#[derive(Default)] +pub struct Discovery { + /// The (relay, group) references of every cached group list. + refs: HashMap>, + /// The groups whose metadata has resolved so far. + discovered: Vec, + /// The in-flight preview subscriptions, keyed by subscription id. + previews: HashMap, + /// Whether the contacts' group lists have been answered. + lists_settled: bool, + /// Whether the pool-wide metadata discovery has been answered. + sweep_settled: bool, +} + +impl Discovery { + pub fn discovered(&self) -> &[GroupCandidate] { + &self.discovered + } + + pub fn loading(&self) -> bool { + !self.lists_settled || !self.sweep_settled || !self.previews.is_empty() + } + + /// Whether the contacts' group lists have been answered. + pub(crate) fn lists_settled(&self) -> bool { + self.lists_settled + } + + /// The discovery is starting over: nothing has answered yet. + pub(crate) fn begin(&mut self, cx: &mut Context) { + self.lists_settled = false; + self.sweep_settled = false; + cx.notify(); + } + + /// There is nothing on the way: there is nothing to discover. + pub(crate) fn settle_all(&mut self, cx: &mut Context) { + self.lists_settled = true; + self.sweep_settled = true; + cx.notify(); + } + + /// The contacts' group lists have been answered. + pub(crate) fn settle_lists(&mut self, cx: &mut Context) { + self.lists_settled = true; + cx.notify(); + } + + /// The pool-wide metadata discovery has been answered. + pub(crate) fn settle_sweep(&mut self, cx: &mut Context) { + self.sweep_settled = true; + cx.notify(); + } + + /// Absorb the references of a group list, skipping the joined groups. + /// + /// Returns whether new references were added. + pub(crate) fn absorb_list( + &mut self, + list: &GroupList, + joined: &HashSet<(RelayUrl, GroupId)>, + cx: &mut Context, + ) -> bool { + let mut added = false; + + for entry in list.entries() { + if joined.contains(&(entry.relay.clone(), entry.id.clone())) { + continue; + } + + if self + .refs + .entry(entry.relay.clone()) + .or_default() + .insert(entry.id.clone()) + { + added = true; + } + } + + if added { + cx.notify(); + } + + added + } + + /// The (relay, group) references, for asking the relays for metadata. + pub(crate) fn refs(&self) -> &HashMap> { + &self.refs + } + + /// Whether a preview subscription is already asking this relay. + pub(crate) fn preview_in_flight(&self, relay: &RelayUrl) -> bool { + self.previews.values().any(|in_flight| in_flight == relay) + } + + /// The relay a preview subscription is asking, if it is in flight. + pub(crate) fn preview_relay(&self, id: &SubscriptionId) -> Option { + self.previews.get(id).cloned() + } + + /// The ids of the in-flight preview subscriptions. + pub(crate) fn preview_ids(&self) -> Vec { + self.previews.keys().cloned().collect() + } + + /// A preview subscription starts asking a relay for metadata. + pub(crate) fn track_preview(&mut self, id: SubscriptionId, relay: RelayUrl) { + self.previews.insert(id, relay); + } + + /// A preview subscription has been answered. + /// + /// Returns whether it was in flight. + pub(crate) fn settle_preview(&mut self, id: &SubscriptionId, cx: &mut Context) -> bool { + let removed = self.previews.remove(id).is_some(); + + if removed { + cx.notify(); + } + + removed + } + + /// Add or replace a discovered group. + pub(crate) fn upsert(&mut self, candidate: GroupCandidate, cx: &mut Context) { + match self.discovered.iter_mut().find(|d| d.key == candidate.key) { + Some(existing) => *existing = candidate, + None => self.discovered.push(candidate), + } + + self.discovered.sort_by(|a, b| { + a.display_name() + .to_lowercase() + .cmp(&b.display_name().to_lowercase()) + }); + + cx.notify(); + } + + pub(crate) fn reset(&mut self, cx: &mut Context) { + self.refs.clear(); + self.discovered.clear(); + self.previews.clear(); + self.lists_settled = false; + self.sweep_settled = false; + cx.notify(); + } +} diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index 51286642..004562a2 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -10,9 +10,11 @@ use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; +mod discovery; mod group; mod protocol; +pub use discovery::Discovery; pub use group::*; pub use protocol::*; @@ -21,6 +23,7 @@ pub(crate) static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); const LIST_SUBSCRIPTION: &str = "nip29-list"; const FORK_SUBSCRIPTION: &str = "nip29-forks"; const CONTACTS_SUBSCRIPTION: &str = "nip29-contacts"; +const DISCOVERY_SUBSCRIPTION: &str = "nip29-discovery"; const GROUP_PREFIX: &str = "grp-"; const BROWSE_PREFIX: &str = "brw-"; const PREVIEW_PREFIX: &str = "prv-"; @@ -43,18 +46,17 @@ pub enum GroupsEvent { Error(String), Browsed(Box), BrowseSettled(RelayUrl), - Suggested(Box), - SuggestSettled, } #[derive(Debug)] enum Signal { - Event(SubscriptionId, Event), + Event(SubscriptionId, RelayUrl, Box), Eose(SubscriptionId), Closed(SubscriptionId, String), BrowseSettled(SubscriptionId), ContactListsSettled, ContactPreviewSettled(SubscriptionId), + DiscoverySettled, Forks, } @@ -70,6 +72,10 @@ fn contacts_subscription_id() -> SubscriptionId { SubscriptionId::new(CONTACTS_SUBSCRIPTION) } +fn discovery_subscription_id() -> SubscriptionId { + SubscriptionId::new(DISCOVERY_SUBSCRIPTION) +} + fn subscription_id(key: &GroupKey) -> SubscriptionId { SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32)) } @@ -101,6 +107,7 @@ fn is_own_subscription(id: &SubscriptionId) -> bool { id == LIST_SUBSCRIPTION || id == FORK_SUBSCRIPTION || id == CONTACTS_SUBSCRIPTION + || id == DISCOVERY_SUBSCRIPTION || id.starts_with(GROUP_PREFIX) || id.starts_with(BROWSE_PREFIX) || id.starts_with(PREVIEW_PREFIX) @@ -117,16 +124,8 @@ pub struct GroupsRegistry { observers: HashMap, forks: HashMap, fork_authors: HashSet, - /// The follow-list authors the contacts subscription covers. - contact_authors: HashSet, - /// The (relay, group) references of the contacts, minus the joined groups. - contact_refs: HashMap>, - /// The contact groups whose metadata has resolved so far. - suggestions: Vec, - /// The in-flight preview subscriptions, keyed by subscription id. - previews: HashMap, - /// Whether the contacts' group lists have been answered. - contact_settled: bool, + /// The groups discovered from the cache and the relays. + discovery: Entity, signal_tx: flume::Sender, signal_rx: flume::Receiver, notification_listener: Option>, @@ -181,11 +180,7 @@ impl GroupsRegistry { observers: HashMap::new(), forks: HashMap::new(), fork_authors: HashSet::new(), - contact_authors: HashSet::new(), - contact_refs: HashMap::new(), - suggestions: Vec::new(), - previews: HashMap::new(), - contact_settled: false, + discovery: cx.new(|_| Discovery::default()), signal_tx: tx, signal_rx: rx, notification_listener: None, @@ -314,16 +309,32 @@ impl GroupsRegistry { cx.emit(GroupsEvent::BrowseSettled(relay)); } - pub fn load_contacts_groups(&mut self, cx: &mut Context) { + /// The groups discovered from the cache and the relays. + pub fn discovery(&self) -> Entity { + self.discovery.clone() + } + + pub fn discover(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); if nostr.read(cx).current_user().is_none() { + // There is nothing to discover without a user. + self.discovery.update(cx, |discovery, cx| { + discovery.settle_all(cx); + }); return; } - let task: Task, Error>> = cx.background_spawn(async move { + // Every open sweeps the relays again, starting from the cache. + self.discovery.update(cx, |discovery, cx| { + discovery.begin(cx); + }); + + type Cached = (HashSet, Vec); + + let task: Task> = cx.background_spawn(async move { let me = signer.get_public_key_async().await?; let filter = Filter::new().author(me).kind(Kind::ContactList).limit(1); @@ -336,48 +347,26 @@ impl GroupsRegistry { .map(|event| event.tags.public_keys().collect()) .unwrap_or_default(); - Ok(contacts) + let lists = client + .database() + .query(Filter::new().kind(Kind::SimpleGroups)) + .await? + .into_iter() + .collect(); + + Ok((contacts, lists)) }); self.tasks.push(cx.spawn(async move |this, cx| { - let authors = match task.await { - Ok(authors) => authors, + let (authors, lists) = match task.await { + Ok(ok) => ok, Err(error) => { - log::warn!("nip29: loading the contact list failed: {error}"); - return; + log::warn!("nip29: loading the cached groups failed: {error}"); + (HashSet::new(), Vec::new()) } }; - if authors.is_empty() { - if let Err(error) = this.update(cx, |this, cx| { - // Nobody is followed, so there is nothing to discover. - this.contact_settled = true; - cx.emit(GroupsEvent::SuggestSettled); - cx.notify(); - }) { - log::warn!("nip29: settling the contacts failed: {error}"); - } - return; - } - - // Show the cached lists before the relays answer. - let lists = match this.update(cx, |this, cx| this.start_contacts_hydration(authors, cx)) - { - Ok(Some(task)) => match task.await { - Ok(lists) => lists, - Err(error) => { - log::warn!("nip29: loading the cached group lists failed: {error}"); - Vec::new() - } - }, - Ok(None) => return, - Err(error) => { - log::warn!("nip29: hydrating the contacts failed: {error}"); - return; - } - }; - - let refs = match this.update(cx, |this, cx| { + if let Err(error) = this.update(cx, |this, cx| { let newest: HashMap = lists.into_iter().fold(HashMap::new(), |mut newest, event| { match newest.get(&event.pubkey) { @@ -390,20 +379,14 @@ impl GroupsRegistry { }); for event in newest.into_values() { - this.absorb_contact_list(&event, cx); + this.absorb_group_list(&event, cx); } - - this.contact_refs.clone() }) { - Ok(refs) => refs, - Err(error) => { - log::warn!("nip29: applying the cached group lists failed: {error}"); - return; - } - }; + log::warn!("nip29: applying the cached group lists failed: {error}"); + return; + } - // Show the cached metadata before the relays answer. - let previews = match this.update(cx, |this, cx| this.load_cached_previews(refs, cx)) { + let previews = match this.update(cx, |this, cx| this.load_cached_previews(cx)) { Ok(task) => match task.await { Ok(previews) => previews, Err(error) => { @@ -412,67 +395,25 @@ impl GroupsRegistry { } }, Err(error) => { - log::warn!("nip29: hydrating the contacts failed: {error}"); + log::warn!("nip29: discovering the groups failed: {error}"); return; } }; if let Err(error) = this.update(cx, |this, cx| { this.absorb_cached_previews(previews, cx); - this.subscribe_contacts(cx); + this.subscribe_contacts(authors, cx); + this.subscribe_discovery(cx); }) { - log::warn!("nip29: applying the cached group metadata failed: {error}"); + log::warn!("nip29: discovering the groups failed: {error}"); } })); } - /// The contact groups whose metadata has resolved so far. - pub fn contacts_candidates(&self) -> &[GroupCandidate] { - &self.suggestions - } - - /// Whether the contact lists or any of their previews are still on the way. - pub fn contacts_loading(&self) -> bool { - !self.contact_settled || !self.previews.is_empty() - } - - /// Reset the contacts state and start loading the cached group lists. - fn start_contacts_hydration( - &mut self, - authors: HashSet, - cx: &mut Context, - ) -> Option, Error>>> { - if authors == self.contact_authors { - return None; - } - - self.contact_authors = authors; - self.contact_refs.clear(); - self.suggestions.clear(); - self.previews.clear(); - self.contact_settled = false; - - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let filter = Filter::new() - .authors(self.contact_authors.iter().copied()) - .kind(Kind::SimpleGroups); - - let task: Task, Error>> = cx.background_spawn(async move { - Ok(client.database().query(filter).await?.into_iter().collect()) - }); - - cx.notify(); - Some(task) - } - /// Load the cached metadata for the referenced groups from the database. - fn load_cached_previews( - &self, - refs: HashMap>, - cx: &mut Context, - ) -> Task, Error>> { + fn load_cached_previews(&self, cx: &mut Context) -> Task, Error>> { + let refs = self.discovery.read(cx).refs().clone(); + if refs.is_empty() { return Task::ready(Ok(Vec::new())); } @@ -488,10 +429,8 @@ impl GroupsRegistry { }) } - /// Seed the suggestions from the cached group metadata. + /// Seed the discoveries from the cached group metadata. fn absorb_cached_previews(&mut self, previews: Vec, cx: &mut Context) { - // Keep the newest metadata per group, the database may still hold - // superseded versions. let newest: HashMap = previews .into_iter() @@ -517,7 +456,9 @@ impl GroupsRegistry { } }); - for (relay, ids) in self.contact_refs.clone() { + let mut candidates = Vec::new(); + + for (relay, ids) in self.discovery.read(cx).refs().clone() { for id in ids { let Some((_, metadata)) = newest.get(&id) else { continue; @@ -528,25 +469,33 @@ impl GroupsRegistry { continue; } - self.upsert_suggestion( - GroupCandidate { - key: GroupKey::new(relay.clone(), id.clone()), - metadata: metadata.clone(), - }, - cx, - ); + candidates.push(GroupCandidate { + key: GroupKey::new(relay.clone(), id.clone()), + metadata: metadata.clone(), + }); } } + + self.discovery.update(cx, |discovery, cx| { + for candidate in candidates { + discovery.upsert(candidate, cx); + } + }); } /// Subscribe to the contacts' public group lists. - fn subscribe_contacts(&mut self, cx: &mut Context) { + fn subscribe_contacts(&mut self, authors: HashSet, cx: &mut Context) { + if authors.is_empty() { + // Nobody is followed, so there is nothing to discover from them. + self.discovery + .update(cx, |discovery, cx| discovery.settle_lists(cx)); + return; + } + let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let filter = Filter::new() - .authors(self.contact_authors.iter().copied()) - .kind(Kind::SimpleGroups); + let filter = Filter::new().authors(authors).kind(Kind::SimpleGroups); let opts = SubscribeAutoCloseOptions::default() .exit_policy(ReqExitPolicy::ExitOnEOSE) @@ -577,19 +526,87 @@ impl GroupsRegistry { cx.notify(); } - /// Ask every relay hosting referenced groups for the groups' metadata. - fn sync_contact_previews(&mut self, cx: &mut Context) { + /// Subscribe to the metadata of every group the pool relays host. + fn subscribe_discovery(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - for (relay, ids) in self.contact_refs.clone() { + let filter = Filter::new().kind(Kind::GroupMetadata).limit(BROWSE_LIMIT); + + let opts = SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE) + .timeout(Some(BROWSE_TIMEOUT)); + + let tx = self.signal_tx.clone(); + + self.tasks.push(cx.background_spawn(async move { + let refused = match client + .subscribe(ReqTarget::auto([filter])) + .with_id(discovery_subscription_id()) + .close_on(opts) + .await + { + Ok(output) => output.success.is_empty(), + Err(error) => { + log::warn!("nip29: the group discovery failed: {error}"); + true + } + }; + + // When every relay refused the request no EOSE will arrive. + if refused && let Err(error) = tx.send_async(Signal::DiscoverySettled).await { + log::warn!("the discovery settle signal was dropped: {error}"); + } + })); + + cx.notify(); + } + + /// Absorb a discovered group metadata event from the relay serving it. + fn absorb_discovered(&mut self, relay: RelayUrl, event: &Event, cx: &mut Context) { + let metadata = match GroupMetadata::parse(event) { + Ok(metadata) => metadata, + Err(error) => { + log::warn!("nip29: ignoring malformed group metadata from {relay}: {error}"); + return; + } + }; + + // Hidden groups are not discoverable (NIP-29). + if metadata.hidden { + return; + } + + let Some(id) = metadata.id().cloned() else { + return; + }; + + self.discovery.update(cx, |discovery, cx| { + discovery.upsert( + GroupCandidate { + key: GroupKey::new(relay, id), + metadata, + }, + cx, + ); + }); + } + + /// Ask every relay hosting referenced groups for the groups' metadata. + fn sync_previews(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + for (relay, ids) in self.discovery.read(cx).refs().clone() { // A subscription is already asking this relay for previews. - if self.previews.values().any(|in_flight| in_flight == &relay) { + if self.discovery.read(cx).preview_in_flight(&relay) { continue; } let id = preview_subscription(&relay); - self.previews.insert(id.clone(), relay.clone()); + self.discovery.update(cx, |discovery, _| { + discovery.track_preview(id.clone(), relay.clone()); + }); let client = client.clone(); let filter = Filter::new() @@ -626,11 +643,9 @@ impl GroupsRegistry { } })); } - - cx.notify(); } - fn absorb_contact_list(&mut self, event: &Event, cx: &mut Context) { + fn absorb_group_list(&mut self, event: &Event, cx: &mut Context) { let Ok(list) = GroupList::parse(event) else { log::warn!( "nip29: ignoring a malformed group list from {}", @@ -648,38 +663,17 @@ impl GroupsRegistry { }) .collect(); - let mut added = false; + let added = self.discovery.update(cx, |discovery, cx| { + discovery.absorb_list(&list, &joined, cx) + }); - for entry in list.entries() { - if joined.contains(&(entry.relay.clone(), entry.id.clone())) { - continue; - } - - if self - .contact_refs - .entry(entry.relay.clone()) - .or_default() - .insert(entry.id.clone()) - { - added = true; - } + if added && self.discovery.read(cx).lists_settled() { + self.sync_previews(cx); } - - if !added { - return; - } - - // Lists may still arrive after the first EOSE, - // so previews sync as the references grow as well. - if self.contact_settled { - self.sync_contact_previews(cx); - } - - cx.notify(); } - fn absorb_contact_group(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context) { - let Some(relay) = self.previews.get(id).cloned() else { + fn absorb_preview(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context) { + let Some(relay) = self.discovery.read(cx).preview_relay(id) else { return; }; @@ -702,75 +696,37 @@ impl GroupsRegistry { }; if !self - .contact_refs + .discovery + .read(cx) + .refs() .get(&relay) .is_some_and(|ids| ids.contains(&group)) { return; } - self.upsert_suggestion( - GroupCandidate { - key: GroupKey::new(relay, group), - metadata, - }, - cx, - ); - } - - fn upsert_suggestion(&mut self, candidate: GroupCandidate, cx: &mut Context) { - match self.suggestions.iter_mut().find(|s| s.key == candidate.key) { - Some(existing) => *existing = candidate.clone(), - None => self.suggestions.push(candidate.clone()), - } - - self.suggestions.sort_by(|a, b| { - a.display_name() - .to_lowercase() - .cmp(&b.display_name().to_lowercase()) + self.discovery.update(cx, |discovery, cx| { + discovery.upsert( + GroupCandidate { + key: GroupKey::new(relay, group), + metadata, + }, + cx, + ); }); - - cx.emit(GroupsEvent::Suggested(Box::new(candidate))); - cx.notify(); - } - - /// Drop suggestions whose group is no longer referenced by any contact. - fn prune_suggestions(&mut self, cx: &mut Context) { - let before = self.suggestions.len(); - - self.suggestions.retain(|suggestion| { - self.contact_refs - .get(suggestion.key.relay()) - .is_some_and(|ids| ids.contains(suggestion.key.id())) - }); - - if self.suggestions.len() != before { - cx.notify(); - } } /// The contacts' group lists have been answered: resolve the metadata. - fn handle_contact_lists_settled(&mut self, cx: &mut Context) { - self.contact_settled = true; - self.sync_contact_previews(cx); - - if self.previews.is_empty() { - self.prune_suggestions(cx); - cx.emit(GroupsEvent::SuggestSettled); - cx.notify(); - } + fn handle_lists_settled(&mut self, cx: &mut Context) { + self.discovery + .update(cx, |discovery, cx| discovery.settle_lists(cx)); + self.sync_previews(cx); } - fn handle_contact_preview_settled(&mut self, id: &SubscriptionId, cx: &mut Context) { - if self.previews.remove(id).is_none() { - return; - } - - if self.previews.is_empty() { - self.prune_suggestions(cx); - cx.emit(GroupsEvent::SuggestSettled); - cx.notify(); - } + fn handle_preview_settled(&mut self, id: &SubscriptionId, cx: &mut Context) { + self.discovery.update(cx, |discovery, cx| { + discovery.settle_preview(id, cx); + }); } pub fn load_more(&mut self, key: &GroupKey, cx: &mut Context) { @@ -829,15 +785,13 @@ impl GroupsRegistry { let mut ids: Vec = routes.into_keys().collect(); ids.push(list_subscription_id()); ids.push(fork_subscription_id()); + ids.push(discovery_subscription_id()); ids.extend(self.browse.keys().cloned()); - ids.extend(self.previews.keys().cloned()); + ids.extend(self.discovery.read(cx).preview_ids()); self.browse.clear(); - self.previews.clear(); - self.contact_authors.clear(); - self.contact_refs.clear(); - self.suggestions.clear(); - self.contact_settled = false; + self.discovery + .update(cx, |discovery, cx| discovery.reset(cx)); self.tasks.push(cx.background_spawn(async move { for id in ids { @@ -1302,7 +1256,13 @@ impl GroupsRegistry { })); } - fn handle_event(&mut self, id: SubscriptionId, event: Event, cx: &mut Context) { + fn handle_event( + &mut self, + id: SubscriptionId, + relay: RelayUrl, + event: Event, + cx: &mut Context, + ) { if id == list_subscription_id() { match GroupList::parse(&event) { Ok(list) => self.merge(list, cx), @@ -1317,7 +1277,12 @@ impl GroupsRegistry { } if id == contacts_subscription_id() { - self.absorb_contact_list(&event, cx); + self.absorb_group_list(&event, cx); + return; + } + + if id == discovery_subscription_id() { + self.absorb_discovered(relay, &event, cx); return; } @@ -1326,8 +1291,8 @@ impl GroupsRegistry { return; } - if self.previews.contains_key(&id) { - self.absorb_contact_group(&id, &event, cx); + if self.discovery.read(cx).preview_relay(&id).is_some() { + self.absorb_preview(&id, &event, cx); return; } @@ -1368,12 +1333,18 @@ impl GroupsRegistry { fn handle_eose(&mut self, id: SubscriptionId, cx: &mut Context) { if id == contacts_subscription_id() { - self.handle_contact_lists_settled(cx); + self.handle_lists_settled(cx); return; } - if self.previews.contains_key(&id) { - self.handle_contact_preview_settled(&id, cx); + if id == discovery_subscription_id() { + self.discovery + .update(cx, |discovery, cx| discovery.settle_sweep(cx)); + return; + } + + if self.discovery.read(cx).preview_relay(&id).is_some() { + self.handle_preview_settled(&id, cx); return; } @@ -1407,13 +1378,20 @@ impl GroupsRegistry { fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context) { if id == contacts_subscription_id() { log::warn!("nip29: the contacts' group lists were refused: {reason}"); - self.handle_contact_lists_settled(cx); + self.handle_lists_settled(cx); return; } - if let Some(relay) = self.previews.get(&id).cloned() { + if id == discovery_subscription_id() { + log::warn!("nip29: the group discovery was closed: {reason}"); + self.discovery + .update(cx, |discovery, cx| discovery.settle_sweep(cx)); + return; + } + + if let Some(relay) = self.discovery.read(cx).preview_relay(&id) { log::warn!("nip29: fetching previews from {relay} was refused: {reason}"); - self.handle_contact_preview_settled(&id, cx); + self.handle_preview_settled(&id, cx); return; } @@ -1474,7 +1452,7 @@ impl GroupsRegistry { const MAX_PROCESSED: usize = 10_000; while let Some(notification) = notifications.next().await { - let ClientNotification::Message { message, .. } = notification else { + let ClientNotification::Message { relay_url, message } = notification else { continue; }; @@ -1493,7 +1471,7 @@ impl GroupsRegistry { } processed .insert(event.id) - .then(|| Signal::Event(id, event.into_owned())) + .then(|| Signal::Event(id, relay_url, Box::new(event.into_owned()))) } } RelayMessage::EndOfStoredEvents(id) => { @@ -1519,8 +1497,8 @@ impl GroupsRegistry { self.signal_consumer = Some(cx.spawn(async move |this, cx| { while let Ok(signal) = rx.recv_async().await { this.update(cx, |this, cx| match signal { - Signal::Event(id, event) => { - this.handle_event(id, event, cx); + Signal::Event(id, relay, event) => { + this.handle_event(id, relay, *event, cx); } Signal::Eose(id) => { this.handle_eose(id, cx); @@ -1532,10 +1510,14 @@ impl GroupsRegistry { this.settle_browse(id, cx); } Signal::ContactListsSettled => { - this.handle_contact_lists_settled(cx); + this.handle_lists_settled(cx); } Signal::ContactPreviewSettled(id) => { - this.handle_contact_preview_settled(&id, cx); + this.handle_preview_settled(&id, cx); + } + Signal::DiscoverySettled => { + this.discovery + .update(cx, |discovery, cx| discovery.settle_sweep(cx)); } Signal::Forks => { this.sync_forks(true, cx); diff --git a/crates/workspace/src/panels/browse.rs b/crates/workspace/src/panels/browse.rs index f38e2109..ffb17c56 100644 --- a/crates/workspace/src/panels/browse.rs +++ b/crates/workspace/src/panels/browse.rs @@ -6,7 +6,7 @@ use gpui::{ IntoElement, ParentElement, Render, SharedString, Styled, Subscription, WeakEntity, Window, div, uniform_list, }; -use nip29::{GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry}; +use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::RelayUrl; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; @@ -35,19 +35,18 @@ pub struct BrowsePanel { relay: Option, /// The groups the relay has announced so far candidates: Vec, - /// The communities of the user's contacts - contacts: Vec, - /// Whether the contacts' communities are still resolving - contacts_loading: bool, + /// The groups discovered from the cache and the relays + discovery: Entity, /// Whether the relay is still answering loading: bool, - _subscriptions: SmallVec<[Subscription; 2]>, + _subscriptions: SmallVec<[Subscription; 3]>, } impl BrowsePanel { fn new(dock: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { let input = cx.new(|cx| InputState::new(window, cx).placeholder(INPUT_PLACEHOLDER)); let registry = GroupsRegistry::global(cx); + let discovery = registry.read(cx).discovery(); let mut subscriptions = smallvec![]; @@ -59,6 +58,10 @@ impl BrowsePanel { }), ); + subscriptions.push(cx.observe(&discovery, |_, _, cx| { + cx.notify(); + })); + subscriptions.push( cx.subscribe(®istry, |this, _registry, event, cx| match event { GroupsEvent::Browsed(candidate) => { @@ -67,19 +70,15 @@ impl BrowsePanel { GroupsEvent::BrowseSettled(relay) => { this.settle(relay.clone(), cx); } - GroupsEvent::Suggested(candidate) => { - this.push_contact(candidate.as_ref().clone(), cx); - } - GroupsEvent::SuggestSettled => { - this.settle_contacts(cx); - } _ => {} }), ); - // Wait for the panel to leave the stack before kicking off the sync. - cx.defer_in(window, |this, _window, cx| { - this.load_contacts(cx); + // Wait for the panel to leave the stack before kicking off the sweep. + cx.defer_in(window, |_this, _window, cx| { + GroupsRegistry::global(cx).update(cx, |registry, cx| { + registry.discover(cx); + }); }); Self { @@ -88,50 +87,12 @@ impl BrowsePanel { input, relay: None, candidates: Vec::new(), - contacts: Vec::new(), - contacts_loading: true, + discovery, loading: false, _subscriptions: subscriptions, } } - /// Pull the resolved contacts' communities and start the contacts sync. - fn load_contacts(&mut self, cx: &mut Context) { - let registry = GroupsRegistry::global(cx); - - let (candidates, loading) = registry.update(cx, |registry, cx| { - registry.load_contacts_groups(cx); - ( - registry.contacts_candidates().to_vec(), - registry.contacts_loading(), - ) - }); - - self.contacts = candidates; - self.contacts_loading = loading; - cx.notify(); - } - - fn push_contact(&mut self, candidate: GroupCandidate, cx: &mut Context) { - match self.contacts.iter_mut().find(|c| c.key == candidate.key) { - Some(existing) => *existing = candidate, - None => self.contacts.push(candidate), - } - - self.contacts.sort_by(|a, b| { - a.display_name() - .to_lowercase() - .cmp(&b.display_name().to_lowercase()) - }); - - cx.notify(); - } - - fn settle_contacts(&mut self, cx: &mut Context) { - self.contacts_loading = false; - cx.notify(); - } - /// Browse the relay in the input. fn browse(&mut self, window: &mut Window, cx: &mut Context) { let registry = GroupsRegistry::global(cx); @@ -211,10 +172,10 @@ impl BrowsePanel { range: Range, cx: &mut Context, ) -> Vec { + let registry = GroupsRegistry::global(cx); let hide_avatar = AppSettings::get_hide_avatar(cx); let panel = cx.entity().downgrade(); - let registry = GroupsRegistry::global(cx); let joined: Vec = registry .read(cx) .groups() @@ -229,16 +190,19 @@ impl BrowsePanel { .map(|candidate| { let key = candidate.key.clone(); let name = SharedString::from(candidate.display_name().to_owned()); + + let seed = key.cache_tag(); + let is_joined = joined.contains(&key); + let picture = candidate .display_image() .map(|picture| SharedString::from(picture.to_owned())); - let seed = key.cache_tag(); + let detail = if relay_detail { key.relay().domain().unwrap_or_else(|| key.relay().as_str()) } else { key.id().as_str() }; - let is_joined = joined.contains(&key); let action = if is_joined { Button::new(format!("browse-joined-{seed}")) @@ -335,9 +299,9 @@ impl Focusable for BrowsePanel { impl Render for BrowsePanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let count = self.candidates.len(); - let from_contacts = self.relay.is_none(); + let discover = self.relay.is_none(); - let (contacts, contact_count) = if from_contacts { + let (discovered, discovered_count, discovering) = if discover { let registry = GroupsRegistry::global(cx); let joined: Vec = registry .read(cx) @@ -346,34 +310,37 @@ impl Render for BrowsePanel { .map(|group| group.read(cx).key().clone()) .collect(); - let contacts: Vec = self - .contacts + let discovery = self.discovery.read(cx); + let discovering = discovery.loading(); + + let discovered: Vec = discovery + .discovered() .iter() .filter(|candidate| !joined.contains(&candidate.key)) .cloned() .collect(); - let count = contacts.len(); - (contacts, count) + let count = discovered.len(); + (discovered, count, discovering) } else { - (Vec::new(), 0) + (Vec::new(), 0, false) }; - let body = if from_contacts { - if contact_count > 0 { + let body = if discover { + if discovered_count > 0 { uniform_list( - "browse-contacts", - contact_count, + "browse-discovered", + discovered_count, cx.processor(move |this, range, _window, cx| { - this.render_rows(&contacts, true, range, cx) + this.render_rows(&discovered, true, range, cx) }), ) .h_full() .into_any_element() - } else if self.contacts_loading { - notice("Loading communities from your contacts…", cx) + } else if discovering { + notice("Discovering groups…", cx) } else { - notice("No communities from your contacts.", cx) + notice("No groups found.", cx) } } else if count > 0 { uniform_list( @@ -407,19 +374,18 @@ impl Render for BrowsePanel { this.browse(window, cx); })), ) - .when( - self.loading || (from_contacts && self.contacts_loading), - |this| this.child(Indicator::new().small().color(cx.theme().icon_accent)), - ), + .when(self.loading || (discover && discovering), |this| { + this.child(Indicator::new().small().color(cx.theme().icon_accent)) + }), ) - .when(from_contacts && contact_count > 0, |this| { + .when(discover && discovered_count > 0, |this| { this.child( div() .px_3() .pb_1() .text_xs() .text_color(cx.theme().text_muted) - .child("From your contacts"), + .child("Discovered groups"), ) }) .child(div().flex_1().min_h_0().child(body)) -- 2.54.0 From d0f02dfb4bcd734e9c127b5c003262274a8a5e33 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 7 Oct 2026 10:31:16 +0700 Subject: [PATCH 03/10] update browse panel --- Cargo.lock | 1 + crates/nip29/src/discovery.rs | 17 +- crates/nip29/src/lib.rs | 125 ++++++++++- crates/nip29/src/protocol.rs | 4 + crates/workspace/Cargo.toml | 1 + crates/workspace/src/panels/browse.rs | 294 +++++++++++++++++--------- 6 files changed, 340 insertions(+), 102 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e525bb86..8dcc381a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9409,6 +9409,7 @@ dependencies = [ "chat", "chat_ui", "common", + "gpui-base", "gpui-pre", "instant", "log", diff --git a/crates/nip29/src/discovery.rs b/crates/nip29/src/discovery.rs index 353f56b7..f446ffe5 100644 --- a/crates/nip29/src/discovery.rs +++ b/crates/nip29/src/discovery.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use gpui::Context; use nostr_sdk::prelude::*; -use crate::protocol::{GroupCandidate, GroupId, GroupList}; +use crate::protocol::{GroupCandidate, GroupId, GroupKey, GroupList}; /// The groups discovered from the cache and the relays. #[derive(Default)] @@ -12,6 +12,8 @@ pub struct Discovery { refs: HashMap>, /// The groups whose metadata has resolved so far. discovered: Vec, + /// The member counts of the known groups. + members: HashMap, /// The in-flight preview subscriptions, keyed by subscription id. previews: HashMap, /// Whether the contacts' group lists have been answered. @@ -147,9 +149,22 @@ impl Discovery { cx.notify(); } + /// Apply a member count for a group. + pub(crate) fn set_members(&mut self, key: GroupKey, members: usize, cx: &mut Context) { + if self.members.insert(key, members) != Some(members) { + cx.notify(); + } + } + + /// The member count of a group, when its member list is known. + pub fn members(&self, key: &GroupKey) -> Option { + self.members.get(key).copied() + } + pub(crate) fn reset(&mut self, cx: &mut Context) { self.refs.clear(); self.discovered.clear(); + self.members.clear(); self.previews.clear(); self.lists_settled = false; self.sweep_settled = false; diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index 004562a2..905f80b4 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -114,6 +114,18 @@ fn is_own_subscription(id: &SubscriptionId) -> bool { || id.starts_with(PAGE_PREFIX) } +/// The group id and member count of a group members event. +fn parse_members(event: &Event) -> Option<(GroupId, usize)> { + if event.kind != Kind::GroupMembers { + return None; + } + + let id = GroupId::new(event.tags.identifier()?).ok()?; + let members = event.tags.public_keys().collect::>().len(); + + Some((id, members)) +} + impl EventEmitter for GroupsRegistry {} pub struct GroupsRegistry { @@ -282,13 +294,15 @@ impl GroupsRegistry { self.tasks.push(cx.background_spawn(async move { client.add_relay(relay.clone()).and_connect().await.ok(); - let filter = Filter::new().kind(Kind::GroupMetadata).limit(BROWSE_LIMIT); + let metadata = Filter::new().kind(Kind::GroupMetadata).limit(BROWSE_LIMIT); + let members = Filter::new().kind(Kind::GroupMembers).limit(BROWSE_LIMIT); + let opts = SubscribeAutoCloseOptions::default() .exit_policy(ReqExitPolicy::ExitOnEOSE) .timeout(Some(BROWSE_TIMEOUT)); if let Err(error) = client - .subscribe(ReqTarget::single(relay.clone(), [filter])) + .subscribe(ReqTarget::single(relay.clone(), [metadata, members])) .with_id(id.clone()) .close_on(opts) .await @@ -386,6 +400,7 @@ impl GroupsRegistry { return; } + // Show the cached metadata before the relays answer. let previews = match this.update(cx, |this, cx| this.load_cached_previews(cx)) { Ok(task) => match task.await { Ok(previews) => previews, @@ -400,8 +415,27 @@ impl GroupsRegistry { } }; + // Cached member lists fill the cards' member counts. + let members = match this.update(cx, |this, cx| this.load_cached_members(cx)) { + Ok(task) => match task.await { + Ok(members) => members, + Err(error) => { + log::warn!("nip29: loading the cached group members failed: {error}"); + Vec::new() + } + }, + Err(error) => { + log::warn!("nip29: discovering the groups failed: {error}"); + return; + } + }; + if let Err(error) = this.update(cx, |this, cx| { this.absorb_cached_previews(previews, cx); + this.absorb_cached_members(members, cx); + + // The contacts' lists resolve groups on relays outside the + // pool, the sweep covers every relay in it. this.subscribe_contacts(authors, cx); this.subscribe_discovery(cx); }) { @@ -483,6 +517,59 @@ impl GroupsRegistry { }); } + /// Load the cached member lists for the referenced groups from the database. + fn load_cached_members(&self, cx: &mut Context) -> Task, Error>> { + let refs = self.discovery.read(cx).refs().clone(); + + if refs.is_empty() { + return Task::ready(Ok(Vec::new())); + } + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let ids: HashSet<&str> = refs.values().flatten().map(GroupId::as_str).collect(); + let filter = Filter::new().kind(Kind::GroupMembers).identifiers(ids); + + cx.background_spawn(async move { + Ok(client.database().query(filter).await?.into_iter().collect()) + }) + } + + /// Seed the member counts from the cached member lists. + fn absorb_cached_members(&mut self, members: Vec, cx: &mut Context) { + let newest: HashMap = + members + .into_iter() + .fold(HashMap::new(), |mut newest, event| { + let Some((id, count)) = parse_members(&event) else { + return newest; + }; + + match newest.get(&id) { + Some((created_at, _)) if *created_at >= event.created_at => newest, + _ => { + newest.insert(id, (event.created_at, count)); + newest + } + } + }); + + for (relay, ids) in self.discovery.read(cx).refs().clone() { + for id in ids { + let Some((_, count)) = newest.get(&id) else { + continue; + }; + + let count = *count; + + self.discovery.update(cx, |discovery, cx| { + discovery.set_members(GroupKey::new(relay.clone(), id.clone()), count, cx); + }); + } + } + } + /// Subscribe to the contacts' public group lists. fn subscribe_contacts(&mut self, authors: HashSet, cx: &mut Context) { if authors.is_empty() { @@ -531,7 +618,8 @@ impl GroupsRegistry { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let filter = Filter::new().kind(Kind::GroupMetadata).limit(BROWSE_LIMIT); + let metadata = Filter::new().kind(Kind::GroupMetadata).limit(BROWSE_LIMIT); + let members = Filter::new().kind(Kind::GroupMembers).limit(BROWSE_LIMIT); let opts = SubscribeAutoCloseOptions::default() .exit_policy(ReqExitPolicy::ExitOnEOSE) @@ -541,7 +629,7 @@ impl GroupsRegistry { self.tasks.push(cx.background_spawn(async move { let refused = match client - .subscribe(ReqTarget::auto([filter])) + .subscribe(ReqTarget::auto([metadata, members])) .with_id(discovery_subscription_id()) .close_on(opts) .await @@ -564,6 +652,13 @@ impl GroupsRegistry { /// Absorb a discovered group metadata event from the relay serving it. fn absorb_discovered(&mut self, relay: RelayUrl, event: &Event, cx: &mut Context) { + if let Some((id, members)) = parse_members(event) { + self.discovery.update(cx, |discovery, cx| { + discovery.set_members(GroupKey::new(relay, id), members, cx); + }); + return; + } + let metadata = match GroupMetadata::parse(event) { Ok(metadata) => metadata, Err(error) => { @@ -609,10 +704,14 @@ impl GroupsRegistry { }); let client = client.clone(); - let filter = Filter::new() + let metadata = Filter::new() .kind(Kind::GroupMetadata) .identifiers(ids.iter().map(GroupId::as_str)); + let members = Filter::new() + .kind(Kind::GroupMembers) + .identifiers(ids.iter().map(GroupId::as_str)); + let opts = SubscribeAutoCloseOptions::default() .exit_policy(ReqExitPolicy::ExitOnEOSE) .timeout(Some(BROWSE_TIMEOUT)); @@ -623,7 +722,7 @@ impl GroupsRegistry { client.add_relay(relay.clone()).and_connect().await.ok(); let refused = match client - .subscribe(ReqTarget::single(relay.clone(), [filter])) + .subscribe(ReqTarget::single(relay.clone(), [metadata, members])) .with_id(id.clone()) .close_on(opts) .await @@ -677,6 +776,13 @@ impl GroupsRegistry { return; }; + if let Some((group, members)) = parse_members(event) { + self.discovery.update(cx, |discovery, cx| { + discovery.set_members(GroupKey::new(relay, group), members, cx); + }); + return; + } + let metadata = match GroupMetadata::parse(event) { Ok(metadata) => metadata, Err(error) => { @@ -1316,6 +1422,13 @@ impl GroupsRegistry { return; }; + if let Some((group, members)) = parse_members(event) { + self.discovery.update(cx, |discovery, cx| { + discovery.set_members(GroupKey::new(relay, group), members, cx); + }); + return; + } + let Ok(metadata) = GroupMetadata::parse(event) else { log::warn!("nip29: ignoring malformed group metadata from {relay}"); return; diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index 4bbe2fc1..1607b2b0 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -425,6 +425,10 @@ impl GroupCandidate { pub fn display_image(&self) -> Option<&str> { self.metadata.picture.as_deref() } + + pub fn display_about(&self) -> Option<&str> { + self.metadata.about.as_deref() + } } #[derive(Debug, Clone, Default)] diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 3735ac7f..10649483 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -19,6 +19,7 @@ person = { path = "../person" } auto_update = { path = "../auto_update" } gpui.workspace = true +gpui-base.workspace = true nostr-sdk.workspace = true instant.workspace = true nostr-connect.workspace = true diff --git a/crates/workspace/src/panels/browse.rs b/crates/workspace/src/panels/browse.rs index ffb17c56..7fcef5dc 100644 --- a/crates/workspace/src/panels/browse.rs +++ b/crates/workspace/src/panels/browse.rs @@ -1,11 +1,13 @@ use std::ops::Range; +use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - IntoElement, ParentElement, Render, SharedString, Styled, Subscription, WeakEntity, Window, - div, uniform_list, + IntoElement, ParentElement, Render, ScrollStrategy, SharedString, Styled, Subscription, + WeakEntity, Window, div, px, size, }; +use gpui_base::{VirtualListScrollHandle, v_virtual_list}; use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::RelayUrl; use settings::AppSettings; @@ -17,11 +19,16 @@ use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; use ui::indicator::Indicator; use ui::input::{Input, InputEvent, InputState}; use ui::notification::Notification; +use ui::scroll::Scrollbar; use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; const TITLE: &str = "Browse"; const INPUT_PLACEHOLDER: &str = "wss://relay.example.com"; +const CARD_HEIGHT: f32 = 150.0; +const DESCRIPTION_HEIGHT: f32 = 32.0; +const NO_DESCRIPTION: &str = "No description"; + pub fn init(dock: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| BrowsePanel::new(dock, window, cx)) } @@ -37,6 +44,8 @@ pub struct BrowsePanel { candidates: Vec, /// The groups discovered from the cache and the relays discovery: Entity, + /// The scroll position of the group list + scroll_handle: VirtualListScrollHandle, /// Whether the relay is still answering loading: bool, _subscriptions: SmallVec<[Subscription; 3]>, @@ -88,6 +97,7 @@ impl BrowsePanel { relay: None, candidates: Vec::new(), discovery, + scroll_handle: VirtualListScrollHandle::new(), loading: false, _subscriptions: subscriptions, } @@ -113,6 +123,7 @@ impl BrowsePanel { self.relay = Some(relay.clone()); self.candidates.clear(); self.loading = true; + self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); registry.update(cx, |registry, cx| { registry.browse(relay, cx); @@ -165,16 +176,12 @@ impl BrowsePanel { ); } - fn render_rows( - &self, - candidates: &[GroupCandidate], - relay_detail: bool, + fn render_discovered_rows( + &mut self, range: Range, cx: &mut Context, ) -> Vec { let registry = GroupsRegistry::global(cx); - let hide_avatar = AppSettings::get_hide_avatar(cx); - let panel = cx.entity().downgrade(); let joined: Vec = registry .read(cx) @@ -183,81 +190,170 @@ impl BrowsePanel { .map(|group| group.read(cx).key().clone()) .collect(); - candidates - .get(range) - .into_iter() - .flatten() - .map(|candidate| { - let key = candidate.key.clone(); - let name = SharedString::from(candidate.display_name().to_owned()); + let cards: Vec = self + .discovery + .read(cx) + .discovered() + .iter() + .filter(|candidate| !joined.contains(&candidate.key)) + .skip(range.start * 2) + .take((range.end - range.start) * 2) + .cloned() + .collect(); - let seed = key.cache_tag(); - let is_joined = joined.contains(&key); + self.render_card_rows(&cards, range, cx) + } - let picture = candidate - .display_image() - .map(|picture| SharedString::from(picture.to_owned())); + fn render_browse_rows( + &mut self, + range: Range, + cx: &mut Context, + ) -> Vec { + let start = range.start * 2; + let end = (range.end * 2).min(self.candidates.len()); + let cards = self.candidates.get(start..end).unwrap_or(&[]); - let detail = if relay_detail { - key.relay().domain().unwrap_or_else(|| key.relay().as_str()) - } else { - key.id().as_str() - }; + self.render_card_rows(cards, range, cx) + } - let action = if is_joined { - Button::new(format!("browse-joined-{seed}")) - .label("Joined") - .small() - .disabled(true) - .into_any_element() - } else { - let panel = panel.clone(); - let clicked = key.clone(); - - Button::new(format!("browse-join-{seed}")) - .label("Join") - .small() - .on_click(move |_event, window, cx| { - if let Err(error) = panel.update(cx, |panel, cx| { - panel.join(clicked.clone(), window, cx); - }) { - log::error!("Failed to join a browsed group: {error}"); - } - }) - .into_any_element() - }; + /// A row holds two group cards side by side. + fn render_card_rows( + &self, + cards: &[GroupCandidate], + range: Range, + cx: &mut Context, + ) -> Vec { + range + .map(|row| { + let first = cards.get(row * 2); + let second = cards.get(row * 2 + 1); h_flex() .w_full() + .h(px(CARD_HEIGHT)) + .p_2() .gap_2() - .px_1p5() - .py_1() - .when(!hide_avatar, |this| { - this.child( - Avatar::from_source(picture) - .seed(seed) - .small() - .flex_shrink_0(), - ) - }) - .child( - v_flex() - .flex_1() - .min_w_0() - .child(div().text_sm().font_medium().truncate().child(name)) - .child( - div() - .text_xs() - .text_color(cx.theme().text_placeholder) - .truncate() - .child(SharedString::from(detail.to_owned())), - ), - ) - .child(action) + .children(first.map(|card| self.render_card(card, cx))) + .children(second.map(|card| self.render_card(card, cx))) .into_any_element() }) .collect() } + + fn render_card(&self, candidate: &GroupCandidate, cx: &mut Context) -> AnyElement { + let key = candidate.key.clone(); + let seed = key.cache_tag(); + let members = self.discovery.read(cx).members(&key).unwrap_or(0); + + let name = SharedString::from(candidate.display_name().to_owned()); + let relay = SharedString::from(key.relay().as_str().to_owned()); + + let picture = candidate + .display_image() + .map(|picture| SharedString::from(picture.to_owned())); + + let about = candidate + .display_about() + .map(|about| SharedString::from(about.to_owned())); + + let registry = GroupsRegistry::global(cx); + let hide_avatar = AppSettings::get_hide_avatar(cx); + let joined = registry.read(cx).group(&key, cx).is_some(); + + let action = if joined { + Button::new(format!("browse-joined-{seed}")) + .label("Joined") + .small() + .ghost() + .disabled(true) + .into_any_element() + } else { + let panel = cx.entity().downgrade(); + let clicked = key; + + Button::new(format!("browse-join-{seed}")) + .label("Join") + .small() + .ghost() + .on_click(move |_event, window, cx| { + let _ = panel.update(cx, |panel, cx| { + panel.join(clicked.clone(), window, cx); + }); + }) + .into_any_element() + }; + + v_flex() + .flex_1() + .min_w_0() + .h_full() + .overflow_hidden() + .border_1() + .border_color(cx.theme().border) + .rounded_lg() + .bg(cx.theme().surface_background) + .child( + v_flex() + .flex_1() + .min_h_0() + .p_2() + .gap_1p5() + .child( + h_flex() + .w_full() + .items_center() + .gap_1p5() + .when(!hide_avatar, |this| { + this.child( + Avatar::from_source(picture) + .seed(seed) + .small() + .flex_shrink_0(), + ) + }) + .child( + v_flex() + .flex_1() + .min_w_0() + .child(div().text_sm().font_medium().truncate().child(name)) + .child( + div() + .text_xs() + .text_color(cx.theme().text_placeholder) + .truncate() + .child(relay), + ), + ) + .child(action), + ) + .child( + div() + .h(px(DESCRIPTION_HEIGHT)) + .text_xs() + .text_color(cx.theme().text_muted) + .when_none(&about, |this| { + this.child( + div() + .text_color(cx.theme().text_placeholder) + .child(NO_DESCRIPTION), + ) + }) + .when_some(about, |this, about| this.child(about)), + ) + .child(div().flex_1()) + .child( + h_flex() + .w_full() + .items_center() + .gap_1() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(Icon::new(IconName::Group).text_color(cx.theme().icon_muted)) + .child(format!("{members} members")), + ), + ) + .into_any_element() + } } fn notice(text: &'static str, cx: &App) -> AnyElement { @@ -301,8 +397,11 @@ impl Render for BrowsePanel { let count = self.candidates.len(); let discover = self.relay.is_none(); - let (discovered, discovered_count, discovering) = if discover { + let (discovered_count, discovering) = if discover { let registry = GroupsRegistry::global(cx); + let discovery = self.discovery.read(cx); + let discovering = discovery.loading(); + let joined: Vec = registry .read(cx) .groups() @@ -310,31 +409,29 @@ impl Render for BrowsePanel { .map(|group| group.read(cx).key().clone()) .collect(); - let discovery = self.discovery.read(cx); - let discovering = discovery.loading(); - - let discovered: Vec = discovery + let discovered_count = discovery .discovered() .iter() .filter(|candidate| !joined.contains(&candidate.key)) - .cloned() - .collect(); + .count(); - let count = discovered.len(); - (discovered, count, discovering) + (discovered_count, discovering) } else { - (Vec::new(), 0, false) + (0, false) }; let body = if discover { if discovered_count > 0 { - uniform_list( - "browse-discovered", - discovered_count, - cx.processor(move |this, range, _window, cx| { - this.render_rows(&discovered, true, range, cx) - }), + let rows = discovered_count.div_ceil(2); + let item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]); + + v_virtual_list( + cx.entity(), + "discovered", + item_sizes, + |this, range, _, cx| this.render_discovered_rows(range, cx), ) + .track_scroll(&self.scroll_handle) .h_full() .into_any_element() } else if discovering { @@ -343,13 +440,13 @@ impl Render for BrowsePanel { notice("No groups found.", cx) } } else if count > 0 { - uniform_list( - "browse-groups", - count, - cx.processor(|this, range, _window, cx| { - this.render_rows(&this.candidates, false, range, cx) - }), - ) + let rows = count.div_ceil(2); + let item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]); + + v_virtual_list(cx.entity(), "groups", item_sizes, |this, range, _, cx| { + this.render_browse_rows(range, cx) + }) + .track_scroll(&self.scroll_handle) .h_full() .into_any_element() } else if self.loading { @@ -388,6 +485,13 @@ impl Render for BrowsePanel { .child("Discovered groups"), ) }) - .child(div().flex_1().min_h_0().child(body)) + .child( + div() + .flex_1() + .min_h_0() + .relative() + .child(body) + .child(Scrollbar::vertical(&self.scroll_handle)), + ) } } -- 2.54.0 From e4720779413144ff957f0057abc13c4b53eff6f3 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 7 Oct 2026 11:54:33 +0700 Subject: [PATCH 04/10] simplify nip29 discovery and sync metadata via negentropy --- Cargo.lock | 2 + crates/nip29/Cargo.toml | 2 + crates/nip29/src/discovery.rs | 107 ++---- crates/nip29/src/lib.rs | 528 +++++++++++++------------- crates/nip29/src/protocol.rs | 21 +- crates/workspace/src/panels/browse.rs | 126 +++--- 6 files changed, 377 insertions(+), 409 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8dcc381a..7a399e2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4517,9 +4517,11 @@ dependencies = [ "anyhow", "chat", "flume 0.11.1", + "futures", "gpui-pre", "log", "nostr-sdk", + "serde_json", "smallvec", "state", ] diff --git a/crates/nip29/Cargo.toml b/crates/nip29/Cargo.toml index ba605cc0..48332da4 100644 --- a/crates/nip29/Cargo.toml +++ b/crates/nip29/Cargo.toml @@ -7,6 +7,8 @@ publish.workspace = true [dependencies] nostr-sdk.workspace = true anyhow.workspace = true +futures.workspace = true +serde_json.workspace = true state = { path = "../state" } chat = { path = "../chat" } diff --git a/crates/nip29/src/discovery.rs b/crates/nip29/src/discovery.rs index f446ffe5..a1074338 100644 --- a/crates/nip29/src/discovery.rs +++ b/crates/nip29/src/discovery.rs @@ -8,18 +8,10 @@ use crate::protocol::{GroupCandidate, GroupId, GroupKey, GroupList}; /// The groups discovered from the cache and the relays. #[derive(Default)] pub struct Discovery { - /// The (relay, group) references of every cached group list. refs: HashMap>, - /// The groups whose metadata has resolved so far. discovered: Vec, - /// The member counts of the known groups. - members: HashMap, - /// The in-flight preview subscriptions, keyed by subscription id. - previews: HashMap, - /// Whether the contacts' group lists have been answered. - lists_settled: bool, - /// Whether the pool-wide metadata discovery has been answered. - sweep_settled: bool, + members: HashMap, + loading: bool, } impl Discovery { @@ -28,37 +20,18 @@ impl Discovery { } pub fn loading(&self) -> bool { - !self.lists_settled || !self.sweep_settled || !self.previews.is_empty() - } - - /// Whether the contacts' group lists have been answered. - pub(crate) fn lists_settled(&self) -> bool { - self.lists_settled + self.loading } /// The discovery is starting over: nothing has answered yet. pub(crate) fn begin(&mut self, cx: &mut Context) { - self.lists_settled = false; - self.sweep_settled = false; + self.loading = true; cx.notify(); } - /// There is nothing on the way: there is nothing to discover. - pub(crate) fn settle_all(&mut self, cx: &mut Context) { - self.lists_settled = true; - self.sweep_settled = true; - cx.notify(); - } - - /// The contacts' group lists have been answered. - pub(crate) fn settle_lists(&mut self, cx: &mut Context) { - self.lists_settled = true; - cx.notify(); - } - - /// The pool-wide metadata discovery has been answered. - pub(crate) fn settle_sweep(&mut self, cx: &mut Context) { - self.sweep_settled = true; + /// Every pending discovery step has finished. + pub(crate) fn settle(&mut self, cx: &mut Context) { + self.loading = false; cx.notify(); } @@ -100,43 +73,13 @@ impl Discovery { &self.refs } - /// Whether a preview subscription is already asking this relay. - pub(crate) fn preview_in_flight(&self, relay: &RelayUrl) -> bool { - self.previews.values().any(|in_flight| in_flight == relay) - } - - /// The relay a preview subscription is asking, if it is in flight. - pub(crate) fn preview_relay(&self, id: &SubscriptionId) -> Option { - self.previews.get(id).cloned() - } - - /// The ids of the in-flight preview subscriptions. - pub(crate) fn preview_ids(&self) -> Vec { - self.previews.keys().cloned().collect() - } - - /// A preview subscription starts asking a relay for metadata. - pub(crate) fn track_preview(&mut self, id: SubscriptionId, relay: RelayUrl) { - self.previews.insert(id, relay); - } - - /// A preview subscription has been answered. - /// - /// Returns whether it was in flight. - pub(crate) fn settle_preview(&mut self, id: &SubscriptionId, cx: &mut Context) -> bool { - let removed = self.previews.remove(id).is_some(); - - if removed { - cx.notify(); - } - - removed - } - - /// Add or replace a discovered group. + /// Add a discovered group, keeping the newest metadata. pub(crate) fn upsert(&mut self, candidate: GroupCandidate, cx: &mut Context) { match self.discovered.iter_mut().find(|d| d.key == candidate.key) { - Some(existing) => *existing = candidate, + Some(existing) if existing.created_at <= candidate.created_at => { + *existing = candidate; + } + Some(_) => return, None => self.discovered.push(candidate), } @@ -149,25 +92,37 @@ impl Discovery { cx.notify(); } - /// Apply a member count for a group. - pub(crate) fn set_members(&mut self, key: GroupKey, members: usize, cx: &mut Context) { - if self.members.insert(key, members) != Some(members) { + /// Apply a member count, keeping the newest list. + pub(crate) fn set_members( + &mut self, + key: GroupKey, + created_at: Timestamp, + members: usize, + cx: &mut Context, + ) { + if self + .members + .get(&key) + .is_some_and(|(current, _)| *current > created_at) + { + return; + } + + if self.members.insert(key, (created_at, members)) != Some((created_at, members)) { cx.notify(); } } /// The member count of a group, when its member list is known. pub fn members(&self, key: &GroupKey) -> Option { - self.members.get(key).copied() + self.members.get(key).map(|(_, members)| *members) } pub(crate) fn reset(&mut self, cx: &mut Context) { self.refs.clear(); self.discovered.clear(); self.members.clear(); - self.previews.clear(); - self.lists_settled = false; - self.sweep_settled = false; + self.loading = false; cx.notify(); } } diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index 905f80b4..80dbae97 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -22,11 +22,8 @@ pub(crate) static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); const LIST_SUBSCRIPTION: &str = "nip29-list"; const FORK_SUBSCRIPTION: &str = "nip29-forks"; -const CONTACTS_SUBSCRIPTION: &str = "nip29-contacts"; -const DISCOVERY_SUBSCRIPTION: &str = "nip29-discovery"; const GROUP_PREFIX: &str = "grp-"; const BROWSE_PREFIX: &str = "brw-"; -const PREVIEW_PREFIX: &str = "prv-"; const PAGE_PREFIX: &str = "pg-"; const BROWSE_LIMIT: usize = 500; const BROWSE_TIMEOUT: Duration = Duration::from_secs(10); @@ -50,13 +47,10 @@ pub enum GroupsEvent { #[derive(Debug)] enum Signal { - Event(SubscriptionId, RelayUrl, Box), + Event(SubscriptionId, Box), Eose(SubscriptionId), Closed(SubscriptionId, String), BrowseSettled(SubscriptionId), - ContactListsSettled, - ContactPreviewSettled(SubscriptionId), - DiscoverySettled, Forks, } @@ -68,14 +62,6 @@ fn fork_subscription_id() -> SubscriptionId { SubscriptionId::new(FORK_SUBSCRIPTION) } -fn contacts_subscription_id() -> SubscriptionId { - SubscriptionId::new(CONTACTS_SUBSCRIPTION) -} - -fn discovery_subscription_id() -> SubscriptionId { - SubscriptionId::new(DISCOVERY_SUBSCRIPTION) -} - fn subscription_id(key: &GroupKey) -> SubscriptionId { SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32)) } @@ -87,13 +73,6 @@ fn browse_subscription(relay: &RelayUrl) -> SubscriptionId { SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32)) } -fn preview_subscription(relay: &RelayUrl) -> SubscriptionId { - let mut hasher = DefaultHasher::new(); - relay.hash(&mut hasher); - - SubscriptionId::new(format!("{PREVIEW_PREFIX}{:08x}", hasher.finish() as u32)) -} - fn page_subscription(key: &GroupKey) -> SubscriptionId { SubscriptionId::new(format!("{PAGE_PREFIX}{:08x}", key.uniq_id() as u32)) } @@ -106,16 +85,13 @@ fn is_own_subscription(id: &SubscriptionId) -> bool { let id = id.as_str(); id == LIST_SUBSCRIPTION || id == FORK_SUBSCRIPTION - || id == CONTACTS_SUBSCRIPTION - || id == DISCOVERY_SUBSCRIPTION || id.starts_with(GROUP_PREFIX) || id.starts_with(BROWSE_PREFIX) - || id.starts_with(PREVIEW_PREFIX) || id.starts_with(PAGE_PREFIX) } -/// The group id and member count of a group members event. -fn parse_members(event: &Event) -> Option<(GroupId, usize)> { +/// The group id, member count and timestamp of a group members event. +fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize)> { if event.kind != Kind::GroupMembers { return None; } @@ -123,7 +99,61 @@ fn parse_members(event: &Event) -> Option<(GroupId, usize)> { let id = GroupId::new(event.tags.identifier()?).ok()?; let members = event.tags.public_keys().collect::>().len(); - Some((id, members)) + Some((id, event.created_at, members)) +} + +/// Reconcile a filter with the relays via negentropy, returning the events +/// that were downloaded to the local database. +async fn reconcile( + client: &Client, + filter: Filter, + urls: Vec, +) -> HashMap> { + let output = match client.sync(filter).with(urls).await { + Ok(output) => output, + Err(error) => { + log::warn!("nip29: the reconciliation failed: {error}"); + return HashMap::new(); + } + }; + + for (relay, error) in &output.failed { + log::debug!("nip29: syncing with {relay} failed: {error}"); + } + + output.value.received +} + +/// Pair the downloaded events with the relay that served them. +/// +/// The database does not keep relay associations, so the pairing has to +/// happen while the reconciliation still knows them. +async fn pull_received( + client: &Client, + received: HashMap>, +) -> Result, Error> { + if received.is_empty() { + return Ok(Vec::new()); + } + + let events: HashMap = client + .database() + .query(Filter::new().ids(received.keys().cloned())) + .await? + .into_iter() + .map(|event| (event.id, event)) + .collect(); + + Ok(received + .into_iter() + .flat_map(|(id, relays)| match events.get(&id) { + Some(event) => relays + .into_iter() + .map(|relay| (relay, event.clone())) + .collect(), + None => Vec::new(), + }) + .collect()) } impl EventEmitter for GroupsRegistry {} @@ -336,7 +366,7 @@ impl GroupsRegistry { if nostr.read(cx).current_user().is_none() { // There is nothing to discover without a user. self.discovery.update(cx, |discovery, cx| { - discovery.settle_all(cx); + discovery.settle(cx); }); return; } @@ -381,20 +411,7 @@ impl GroupsRegistry { }; if let Err(error) = this.update(cx, |this, cx| { - let newest: HashMap = - lists.into_iter().fold(HashMap::new(), |mut newest, event| { - match newest.get(&event.pubkey) { - Some(current) if current.created_at >= event.created_at => newest, - _ => { - newest.insert(event.pubkey, event); - newest - } - } - }); - - for event in newest.into_values() { - this.absorb_group_list(&event, cx); - } + this.absorb_lists(lists, cx); }) { log::warn!("nip29: applying the cached group lists failed: {error}"); return; @@ -433,11 +450,82 @@ impl GroupsRegistry { if let Err(error) = this.update(cx, |this, cx| { this.absorb_cached_previews(previews, cx); this.absorb_cached_members(members, cx); + }) { + log::warn!("nip29: discovering the groups failed: {error}"); + return; + } - // The contacts' lists resolve groups on relays outside the - // pool, the sweep covers every relay in it. - this.subscribe_contacts(authors, cx); - this.subscribe_discovery(cx); + // Reconcile the contacts' group lists with the pool relays: only + // the events missing locally are downloaded and stored. + let lists = match this.update(cx, |this, cx| this.sync_lists(authors, cx)) { + Ok(task) => task, + Err(error) => { + log::warn!("nip29: discovering the groups failed: {error}"); + return; + } + }; + + // The pool-wide sweep runs while the lists are reconciling. + let sweep = match this.update(cx, |this, cx| this.sync_sweep(cx)) { + Ok(task) => task, + Err(error) => { + log::warn!("nip29: discovering the groups failed: {error}"); + return; + } + }; + + let lists = match lists.await { + Ok(lists) => lists, + Err(error) => { + log::warn!("nip29: syncing the contacts group lists failed: {error}"); + Vec::new() + } + }; + + if let Err(error) = this.update(cx, |this, cx| { + this.absorb_lists(lists, cx); + }) { + log::warn!("nip29: applying the contacts group lists failed: {error}"); + } + + let sweep = match sweep.await { + Ok(events) => events, + Err(error) => { + log::warn!("nip29: syncing the discovered groups failed: {error}"); + Vec::new() + } + }; + + if let Err(error) = this.update(cx, |this, cx| { + for (relay, event) in sweep { + this.absorb_discovered(relay, &event, cx); + } + }) { + log::warn!("nip29: applying the discovered groups failed: {error}"); + } + + // The previews cover the relays the lists just referenced. + let previews = match this.update(cx, |this, cx| this.sync_previews(cx)) { + Ok(task) => match task.await { + Ok(previews) => previews, + Err(error) => { + log::warn!("nip29: syncing the group previews failed: {error}"); + Vec::new() + } + }, + Err(error) => { + log::warn!("nip29: discovering the groups failed: {error}"); + return; + } + }; + + if let Err(error) = this.update(cx, |this, cx| { + for (relay, event) in previews { + this.absorb_preview(relay, &event, cx); + } + + this.discovery + .update(cx, |discovery, cx| discovery.settle(cx)); }) { log::warn!("nip29: discovering the groups failed: {error}"); } @@ -494,7 +582,7 @@ impl GroupsRegistry { for (relay, ids) in self.discovery.read(cx).refs().clone() { for id in ids { - let Some((_, metadata)) = newest.get(&id) else { + let Some((created_at, metadata)) = newest.get(&id) else { continue; }; @@ -505,6 +593,7 @@ impl GroupsRegistry { candidates.push(GroupCandidate { key: GroupKey::new(relay.clone(), id.clone()), + created_at: *created_at, metadata: metadata.clone(), }); } @@ -542,14 +631,14 @@ impl GroupsRegistry { members .into_iter() .fold(HashMap::new(), |mut newest, event| { - let Some((id, count)) = parse_members(&event) else { + let Some((id, created_at, count)) = parse_members(&event) else { return newest; }; match newest.get(&id) { - Some((created_at, _)) if *created_at >= event.created_at => newest, + Some((current, _)) if *current >= created_at => newest, _ => { - newest.insert(id, (event.created_at, count)); + newest.insert(id, (created_at, count)); newest } } @@ -557,26 +646,34 @@ impl GroupsRegistry { for (relay, ids) in self.discovery.read(cx).refs().clone() { for id in ids { - let Some((_, count)) = newest.get(&id) else { + let Some((created_at, count)) = newest.get(&id) else { continue; }; + let created_at = *created_at; let count = *count; self.discovery.update(cx, |discovery, cx| { - discovery.set_members(GroupKey::new(relay.clone(), id.clone()), count, cx); + discovery.set_members( + GroupKey::new(relay.clone(), id.clone()), + created_at, + count, + cx, + ); }); } } } - /// Subscribe to the contacts' public group lists. - fn subscribe_contacts(&mut self, authors: HashSet, cx: &mut Context) { + /// Reconcile the contacts' group lists with the pool relays, returning + /// the newest lists now stored in the database. + fn sync_lists( + &self, + authors: HashSet, + cx: &mut Context, + ) -> Task, Error>> { if authors.is_empty() { - // Nobody is followed, so there is nothing to discover from them. - self.discovery - .update(cx, |discovery, cx| discovery.settle_lists(cx)); - return; + return Task::ready(Ok(Vec::new())); } let nostr = NostrRegistry::global(cx); @@ -584,77 +681,35 @@ impl GroupsRegistry { let filter = Filter::new().authors(authors).kind(Kind::SimpleGroups); - let opts = SubscribeAutoCloseOptions::default() - .exit_policy(ReqExitPolicy::ExitOnEOSE) - .timeout(Some(BROWSE_TIMEOUT)); + cx.background_spawn(async move { + let urls: Vec = client.relays().await.into_keys().collect(); + reconcile(&client, filter.clone(), urls).await; - let tx = self.signal_tx.clone(); - - self.tasks.push(cx.background_spawn(async move { - let refused = match client - .subscribe(ReqTarget::auto([filter])) - .with_id(contacts_subscription_id()) - .close_on(opts) - .await - { - Ok(output) => output.success.is_empty(), - Err(error) => { - log::warn!("subscribing to the contacts group lists failed: {error}"); - true - } - }; - - // When every relay refused the request no EOSE will arrive. - if refused && let Err(error) = tx.send_async(Signal::ContactListsSettled).await { - log::warn!("the contacts settle signal was dropped: {error}"); - } - })); - - cx.notify(); + Ok(client.database().query(filter).await?.into_iter().collect()) + }) } - /// Subscribe to the metadata of every group the pool relays host. - fn subscribe_discovery(&mut self, cx: &mut Context) { + /// Reconcile the groups of every pool relay, returning the newly + /// downloaded events with the relay that served them. + fn sync_sweep(&self, cx: &mut Context) -> Task, Error>> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let metadata = Filter::new().kind(Kind::GroupMetadata).limit(BROWSE_LIMIT); - let members = Filter::new().kind(Kind::GroupMembers).limit(BROWSE_LIMIT); + let filter = Filter::new().kinds([Kind::GroupMetadata, Kind::GroupMembers]); - let opts = SubscribeAutoCloseOptions::default() - .exit_policy(ReqExitPolicy::ExitOnEOSE) - .timeout(Some(BROWSE_TIMEOUT)); + cx.background_spawn(async move { + let urls: Vec = client.relays().await.into_keys().collect(); + let received = reconcile(&client, filter, urls).await; - let tx = self.signal_tx.clone(); - - self.tasks.push(cx.background_spawn(async move { - let refused = match client - .subscribe(ReqTarget::auto([metadata, members])) - .with_id(discovery_subscription_id()) - .close_on(opts) - .await - { - Ok(output) => output.success.is_empty(), - Err(error) => { - log::warn!("nip29: the group discovery failed: {error}"); - true - } - }; - - // When every relay refused the request no EOSE will arrive. - if refused && let Err(error) = tx.send_async(Signal::DiscoverySettled).await { - log::warn!("the discovery settle signal was dropped: {error}"); - } - })); - - cx.notify(); + pull_received(&client, received).await + }) } /// Absorb a discovered group metadata event from the relay serving it. fn absorb_discovered(&mut self, relay: RelayUrl, event: &Event, cx: &mut Context) { - if let Some((id, members)) = parse_members(event) { + if let Some((id, created_at, members)) = parse_members(event) { self.discovery.update(cx, |discovery, cx| { - discovery.set_members(GroupKey::new(relay, id), members, cx); + discovery.set_members(GroupKey::new(relay, id), created_at, members, cx); }); return; } @@ -680,6 +735,7 @@ impl GroupsRegistry { discovery.upsert( GroupCandidate { key: GroupKey::new(relay, id), + created_at: event.created_at, metadata, }, cx, @@ -687,61 +743,70 @@ impl GroupsRegistry { }); } - /// Ask every relay hosting referenced groups for the groups' metadata. - fn sync_previews(&mut self, cx: &mut Context) { + /// Reconcile the referenced groups with the relays hosting them, + /// returning the newly downloaded events with the relay that served + /// them. + fn sync_previews(&self, cx: &mut Context) -> Task, Error>> { + let refs = self.discovery.read(cx).refs().clone(); + + if refs.is_empty() { + return Task::ready(Ok(Vec::new())); + } + let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - for (relay, ids) in self.discovery.read(cx).refs().clone() { - // A subscription is already asking this relay for previews. - if self.discovery.read(cx).preview_in_flight(&relay) { - continue; + cx.background_spawn(async move { + let fetches = refs.into_iter().map(|(relay, ids)| { + let client = client.clone(); + + async move { + if let Err(error) = client.add_relay(relay.clone()).and_connect().await { + log::warn!("nip29: connecting to {relay} failed: {error}"); + } + + let filter = Filter::new() + .kinds([Kind::GroupMetadata, Kind::GroupMembers]) + .identifiers(ids.iter().map(GroupId::as_str)); + + let urls = vec![relay.clone()]; + let received = reconcile(&client, filter, urls).await; + + (relay, received) + } + }); + + let fetched = futures::future::join_all(fetches).await; + let ids: HashSet = fetched + .iter() + .flat_map(|(_, received)| received.keys().cloned()) + .collect(); + + if ids.is_empty() { + return Ok(Vec::new()); } - let id = preview_subscription(&relay); - self.discovery.update(cx, |discovery, _| { - discovery.track_preview(id.clone(), relay.clone()); - }); - let client = client.clone(); + let events: HashMap = client + .database() + .query(Filter::new().ids(ids)) + .await? + .into_iter() + .map(|event| (event.id, event)) + .collect(); - let metadata = Filter::new() - .kind(Kind::GroupMetadata) - .identifiers(ids.iter().map(GroupId::as_str)); - - let members = Filter::new() - .kind(Kind::GroupMembers) - .identifiers(ids.iter().map(GroupId::as_str)); - - let opts = SubscribeAutoCloseOptions::default() - .exit_policy(ReqExitPolicy::ExitOnEOSE) - .timeout(Some(BROWSE_TIMEOUT)); - - let tx = self.signal_tx.clone(); - - self.tasks.push(cx.background_spawn(async move { - client.add_relay(relay.clone()).and_connect().await.ok(); - - let refused = match client - .subscribe(ReqTarget::single(relay.clone(), [metadata, members])) - .with_id(id.clone()) - .close_on(opts) - .await - { - Ok(output) => output.success.is_empty(), - Err(error) => { - log::warn!("fetching group previews from {relay} failed: {error}"); - true - } - }; - - // When the relay refused the request no EOSE will arrive. - if refused - && let Err(error) = tx.send_async(Signal::ContactPreviewSettled(id)).await - { - log::warn!("the preview settle signal was dropped: {error}"); - } - })); - } + Ok(fetched + .into_iter() + .flat_map(|(relay, received)| { + received + .into_keys() + .filter_map(|id| { + let event = events.get(&id)?; + Some((relay.clone(), event.clone())) + }) + .collect::>() + }) + .collect()) + }) } fn absorb_group_list(&mut self, event: &Event, cx: &mut Context) { @@ -762,23 +827,33 @@ impl GroupsRegistry { }) .collect(); - let added = self.discovery.update(cx, |discovery, cx| { - discovery.absorb_list(&list, &joined, cx) + self.discovery.update(cx, |discovery, cx| { + discovery.absorb_list(&list, &joined, cx); }); + } - if added && self.discovery.read(cx).lists_settled() { - self.sync_previews(cx); + /// Absorb the newest group list of every author. + fn absorb_lists(&mut self, lists: Vec, cx: &mut Context) { + let newest: HashMap = + lists.into_iter().fold(HashMap::new(), |mut newest, event| { + match newest.get(&event.pubkey) { + Some(current) if current.created_at >= event.created_at => newest, + _ => { + newest.insert(event.pubkey, event); + newest + } + } + }); + + for event in newest.into_values() { + self.absorb_group_list(&event, cx); } } - fn absorb_preview(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context) { - let Some(relay) = self.discovery.read(cx).preview_relay(id) else { - return; - }; - - if let Some((group, members)) = parse_members(event) { + fn absorb_preview(&mut self, relay: RelayUrl, event: &Event, cx: &mut Context) { + if let Some((group, created_at, members)) = parse_members(event) { self.discovery.update(cx, |discovery, cx| { - discovery.set_members(GroupKey::new(relay, group), members, cx); + discovery.set_members(GroupKey::new(relay, group), created_at, members, cx); }); return; } @@ -796,7 +871,7 @@ impl GroupsRegistry { return; } - // A relay may answer with more than the filter asked for. + // A relay may answer with more than the reconciliation asked for. let Some(group) = metadata.id().cloned() else { return; }; @@ -815,6 +890,7 @@ impl GroupsRegistry { discovery.upsert( GroupCandidate { key: GroupKey::new(relay, group), + created_at: event.created_at, metadata, }, cx, @@ -822,19 +898,6 @@ impl GroupsRegistry { }); } - /// The contacts' group lists have been answered: resolve the metadata. - fn handle_lists_settled(&mut self, cx: &mut Context) { - self.discovery - .update(cx, |discovery, cx| discovery.settle_lists(cx)); - self.sync_previews(cx); - } - - fn handle_preview_settled(&mut self, id: &SubscriptionId, cx: &mut Context) { - self.discovery.update(cx, |discovery, cx| { - discovery.settle_preview(id, cx); - }); - } - pub fn load_more(&mut self, key: &GroupKey, cx: &mut Context) { let Some(group) = self.group(key, cx) else { return; @@ -891,9 +954,7 @@ impl GroupsRegistry { let mut ids: Vec = routes.into_keys().collect(); ids.push(list_subscription_id()); ids.push(fork_subscription_id()); - ids.push(discovery_subscription_id()); ids.extend(self.browse.keys().cloned()); - ids.extend(self.discovery.read(cx).preview_ids()); self.browse.clear(); self.discovery @@ -1362,13 +1423,7 @@ impl GroupsRegistry { })); } - fn handle_event( - &mut self, - id: SubscriptionId, - relay: RelayUrl, - event: Event, - cx: &mut Context, - ) { + fn handle_event(&mut self, id: SubscriptionId, event: Event, cx: &mut Context) { if id == list_subscription_id() { match GroupList::parse(&event) { Ok(list) => self.merge(list, cx), @@ -1382,26 +1437,11 @@ impl GroupsRegistry { return; } - if id == contacts_subscription_id() { - self.absorb_group_list(&event, cx); - return; - } - - if id == discovery_subscription_id() { - self.absorb_discovered(relay, &event, cx); - return; - } - if self.browse.contains_key(&id) { self.absorb_candidate(&id, &event, cx); return; } - if self.discovery.read(cx).preview_relay(&id).is_some() { - self.absorb_preview(&id, &event, cx); - return; - } - let Some(key) = self.routes.get(&id).cloned() else { return; }; @@ -1422,9 +1462,9 @@ impl GroupsRegistry { return; }; - if let Some((group, members)) = parse_members(event) { + if let Some((group, created_at, members)) = parse_members(event) { self.discovery.update(cx, |discovery, cx| { - discovery.set_members(GroupKey::new(relay, group), members, cx); + discovery.set_members(GroupKey::new(relay, group), created_at, members, cx); }); return; } @@ -1440,27 +1480,12 @@ impl GroupsRegistry { cx.emit(GroupsEvent::Browsed(Box::new(GroupCandidate { key: GroupKey::new(relay, group), + created_at: event.created_at, metadata, }))); } fn handle_eose(&mut self, id: SubscriptionId, cx: &mut Context) { - if id == contacts_subscription_id() { - self.handle_lists_settled(cx); - return; - } - - if id == discovery_subscription_id() { - self.discovery - .update(cx, |discovery, cx| discovery.settle_sweep(cx)); - return; - } - - if self.discovery.read(cx).preview_relay(&id).is_some() { - self.handle_preview_settled(&id, cx); - return; - } - let Some(key) = self.routes.get(&id).cloned() else { return; }; @@ -1489,25 +1514,6 @@ impl GroupsRegistry { } fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context) { - if id == contacts_subscription_id() { - log::warn!("nip29: the contacts' group lists were refused: {reason}"); - self.handle_lists_settled(cx); - return; - } - - if id == discovery_subscription_id() { - log::warn!("nip29: the group discovery was closed: {reason}"); - self.discovery - .update(cx, |discovery, cx| discovery.settle_sweep(cx)); - return; - } - - if let Some(relay) = self.discovery.read(cx).preview_relay(&id) { - log::warn!("nip29: fetching previews from {relay} was refused: {reason}"); - self.handle_preview_settled(&id, cx); - return; - } - if let Some(relay) = self.browse.get(&id) { log::warn!("nip29: browsing {relay} was refused: {reason}"); return; @@ -1565,7 +1571,7 @@ impl GroupsRegistry { const MAX_PROCESSED: usize = 10_000; while let Some(notification) = notifications.next().await { - let ClientNotification::Message { relay_url, message } = notification else { + let ClientNotification::Message { message, .. } = notification else { continue; }; @@ -1584,7 +1590,7 @@ impl GroupsRegistry { } processed .insert(event.id) - .then(|| Signal::Event(id, relay_url, Box::new(event.into_owned()))) + .then(|| Signal::Event(id, Box::new(event.into_owned()))) } } RelayMessage::EndOfStoredEvents(id) => { @@ -1610,8 +1616,8 @@ impl GroupsRegistry { self.signal_consumer = Some(cx.spawn(async move |this, cx| { while let Ok(signal) = rx.recv_async().await { this.update(cx, |this, cx| match signal { - Signal::Event(id, relay, event) => { - this.handle_event(id, relay, *event, cx); + Signal::Event(id, event) => { + this.handle_event(id, *event, cx); } Signal::Eose(id) => { this.handle_eose(id, cx); @@ -1622,16 +1628,6 @@ impl GroupsRegistry { Signal::BrowseSettled(id) => { this.settle_browse(id, cx); } - Signal::ContactListsSettled => { - this.handle_lists_settled(cx); - } - Signal::ContactPreviewSettled(id) => { - this.handle_preview_settled(&id, cx); - } - Signal::DiscoverySettled => { - this.discovery - .update(cx, |discovery, cx| discovery.settle_sweep(cx)); - } Signal::Forks => { this.sync_forks(true, cx); } diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index 1607b2b0..6fc3ab4d 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -335,6 +335,17 @@ impl GroupMetadata { .identifier() .ok_or_else(|| anyhow!("group metadata has no d tag"))?; + let content: serde_json::Value = + serde_json::from_str(event.content.as_str()).unwrap_or(serde_json::Value::Null); + + let field = |key: &str| -> Option { + content + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .or_else(|| tag_value(&event.tags, key).map(str::to_owned)) + }; + let mut supported_kinds: Option> = None; for tag in event @@ -363,10 +374,10 @@ impl GroupMetadata { } Ok(Self { - name: tag_value(&event.tags, "name").map(str::to_owned), - picture: tag_value(&event.tags, "picture").map(str::to_owned), - banner: tag_value(&event.tags, "banner").map(str::to_owned), - about: tag_value(&event.tags, "about").map(str::to_owned), + name: field("name"), + picture: field("picture"), + banner: field("banner"), + about: field("about"), private: has_tag(&event.tags, "private"), restricted: has_tag(&event.tags, "restricted"), hidden: has_tag(&event.tags, "hidden"), @@ -412,6 +423,8 @@ impl GroupMetadata { #[derive(Debug, Clone)] pub struct GroupCandidate { pub key: GroupKey, + /// The timestamp of the metadata event the candidate was built from. + pub(crate) created_at: Timestamp, pub(crate) metadata: GroupMetadata, } diff --git a/crates/workspace/src/panels/browse.rs b/crates/workspace/src/panels/browse.rs index 7fcef5dc..772889e7 100644 --- a/crates/workspace/src/panels/browse.rs +++ b/crates/workspace/src/panels/browse.rs @@ -5,7 +5,7 @@ use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, ScrollStrategy, SharedString, Styled, Subscription, - WeakEntity, Window, div, px, size, + WeakEntity, Window, div, px, relative, size, }; use gpui_base::{VirtualListScrollHandle, v_virtual_list}; use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry}; @@ -24,9 +24,7 @@ use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_fle const TITLE: &str = "Browse"; const INPUT_PLACEHOLDER: &str = "wss://relay.example.com"; - -const CARD_HEIGHT: f32 = 150.0; -const DESCRIPTION_HEIGHT: f32 = 32.0; +const CARD_HEIGHT: f32 = 164.0; const NO_DESCRIPTION: &str = "No description"; pub fn init(dock: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { @@ -201,7 +199,7 @@ impl BrowsePanel { .cloned() .collect(); - self.render_card_rows(&cards, range, cx) + self.render_card_rows(&cards, cx) } fn render_browse_rows( @@ -213,20 +211,23 @@ impl BrowsePanel { let end = (range.end * 2).min(self.candidates.len()); let cards = self.candidates.get(start..end).unwrap_or(&[]); - self.render_card_rows(cards, range, cx) + self.render_card_rows(cards, cx) } /// A row holds two group cards side by side. + /// + /// The cards are the relative slice of the visible rows, so they pair up + /// in order, two per row. fn render_card_rows( &self, cards: &[GroupCandidate], - range: Range, cx: &mut Context, ) -> Vec { - range - .map(|row| { - let first = cards.get(row * 2); - let second = cards.get(row * 2 + 1); + cards + .chunks(2) + .map(|pair| { + let first = pair.first(); + let second = pair.get(1); h_flex() .w_full() @@ -234,7 +235,12 @@ impl BrowsePanel { .p_2() .gap_2() .children(first.map(|card| self.render_card(card, cx))) - .children(second.map(|card| self.render_card(card, cx))) + // Keep an odd last card in one column: an empty spacer + // takes the second slot so the card cannot stretch. + .child(match second { + Some(card) => self.render_card(card, cx), + None => div().flex_1().into_any_element(), + }) .into_any_element() }) .collect() @@ -261,7 +267,7 @@ impl BrowsePanel { let joined = registry.read(cx).group(&key, cx).is_some(); let action = if joined { - Button::new(format!("browse-joined-{seed}")) + Button::new(format!("joined-{seed}")) .label("Joined") .small() .ghost() @@ -271,7 +277,7 @@ impl BrowsePanel { let panel = cx.entity().downgrade(); let clicked = key; - Button::new(format!("browse-join-{seed}")) + Button::new(format!("join-{seed}")) .label("Join") .small() .ghost() @@ -290,37 +296,38 @@ impl BrowsePanel { .overflow_hidden() .border_1() .border_color(cx.theme().border) - .rounded_lg() - .bg(cx.theme().surface_background) + .rounded(cx.theme().radius) .child( v_flex() .flex_1() .min_h_0() .p_2() - .gap_1p5() + .gap_2() .child( h_flex() .w_full() .items_center() - .gap_1p5() + .gap_2() .when(!hide_avatar, |this| { - this.child( - Avatar::from_source(picture) - .seed(seed) - .small() - .flex_shrink_0(), - ) + this.child(Avatar::from_source(picture).seed(seed).flex_shrink_0()) }) .child( v_flex() .flex_1() .min_w_0() - .child(div().text_sm().font_medium().truncate().child(name)) .child( div() + .text_sm() + .truncate() + .font_semibold() + .line_height(relative(1.2)) + .child(name), + ) + .child( + div() + .truncate() .text_xs() .text_color(cx.theme().text_placeholder) - .truncate() .child(relay), ), ) @@ -328,44 +335,37 @@ impl BrowsePanel { ) .child( div() - .h(px(DESCRIPTION_HEIGHT)) - .text_xs() + .min_w_0() + .text_sm() .text_color(cx.theme().text_muted) - .when_none(&about, |this| { - this.child( - div() - .text_color(cx.theme().text_placeholder) - .child(NO_DESCRIPTION), - ) - }) - .when_some(about, |this, about| this.child(about)), + .text_ellipsis() + .line_clamp(3) + .map(|this| { + if let Some(about) = about { + this.child(about) + } else { + this.child(NO_DESCRIPTION) + } + }), ) .child(div().flex_1()) .child( h_flex() .w_full() - .items_center() - .gap_1() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(Icon::new(IconName::Group).text_color(cx.theme().icon_muted)) - .child(format!("{members} members")), + .gap_1p5() + .child(Icon::new(IconName::Group)) + .child( + div() + .text_sm() + .text_color(cx.theme().text_muted) + .child(SharedString::from(format!("{members} members"))), + ), ), ) .into_any_element() } } -fn notice(text: &'static str, cx: &App) -> AnyElement { - h_flex() - .size_full() - .justify_center() - .text_sm() - .text_color(cx.theme().text_muted) - .child(text) - .into_any_element() -} - impl Panel for BrowsePanel { fn panel_id(&self) -> SharedString { TITLE.into() @@ -460,7 +460,7 @@ impl Render for BrowsePanel { .child( h_flex() .gap_2() - .p_3() + .px_2() .flex_shrink_0() .child(Input::new(&self.input).cleanable(true).flex_1()) .child( @@ -475,16 +475,6 @@ impl Render for BrowsePanel { this.child(Indicator::new().small().color(cx.theme().icon_accent)) }), ) - .when(discover && discovered_count > 0, |this| { - this.child( - div() - .px_3() - .pb_1() - .text_xs() - .text_color(cx.theme().text_muted) - .child("Discovered groups"), - ) - }) .child( div() .flex_1() @@ -495,3 +485,13 @@ impl Render for BrowsePanel { ) } } + +fn notice(text: &'static str, cx: &App) -> AnyElement { + h_flex() + .size_full() + .justify_center() + .text_sm() + .text_color(cx.theme().text_muted) + .child(text) + .into_any_element() +} -- 2.54.0 From 586fea796dc8d1914b80f0b4051bdc657e11f53b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 7 Oct 2026 15:19:02 +0700 Subject: [PATCH 05/10] clean up nip29 logging and rework browse panel header --- crates/nip29/src/lib.rs | 139 +++++++++++--------------- crates/workspace/src/panels/browse.rs | 40 ++++---- 2 files changed, 79 insertions(+), 100 deletions(-) diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index 80dbae97..393706f0 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -90,7 +90,6 @@ fn is_own_subscription(id: &SubscriptionId) -> bool { || id.starts_with(PAGE_PREFIX) } -/// The group id, member count and timestamp of a group members event. fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize)> { if event.kind != Kind::GroupMembers { return None; @@ -102,8 +101,6 @@ fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize)> { Some((id, event.created_at, members)) } -/// Reconcile a filter with the relays via negentropy, returning the events -/// that were downloaded to the local database. async fn reconcile( client: &Client, filter: Filter, @@ -112,22 +109,19 @@ async fn reconcile( let output = match client.sync(filter).with(urls).await { Ok(output) => output, Err(error) => { - log::warn!("nip29: the reconciliation failed: {error}"); + log::warn!("the reconciliation failed: {error}"); return HashMap::new(); } }; for (relay, error) in &output.failed { - log::debug!("nip29: syncing with {relay} failed: {error}"); + log::debug!("syncing with {relay} failed: {error}"); } output.value.received } /// Pair the downloaded events with the relay that served them. -/// -/// The database does not keep relay associations, so the pairing has to -/// happen while the reconciliation still knows them. async fn pull_received( client: &Client, received: HashMap>, @@ -202,16 +196,15 @@ impl GroupsRegistry { })); cx.defer(move |cx| { - if let Err(error) = this.update(cx, |this, cx| { + this.update(cx, |this, cx| { this.handle_notifications(cx); this.load_cached(cx); if nostr.read(cx).current_user().is_some() { this.load(cx); } - }) { - log::warn!("nip29: initializing the registry failed: {error}"); - } + }) + .ok(); }); Self { @@ -278,7 +271,7 @@ impl GroupsRegistry { }); } }) { - log::warn!("nip29: marking the refused join failed: {error}"); + log::warn!("marking the refused join failed: {error}"); } } })); @@ -337,11 +330,11 @@ impl GroupsRegistry { .close_on(opts) .await { - log::warn!("nip29: browsing {relay} failed: {error}"); + log::warn!("browsing {relay} failed: {error}"); } if let Err(error) = tx.send_async(Signal::BrowseSettled(id)).await { - log::warn!("nip29: the browse settle signal was dropped: {error}"); + log::warn!("the browse settle signal was dropped: {error}"); } })); } @@ -405,7 +398,7 @@ impl GroupsRegistry { let (authors, lists) = match task.await { Ok(ok) => ok, Err(error) => { - log::warn!("nip29: loading the cached groups failed: {error}"); + log::warn!("loading the cached groups failed: {error}"); (HashSet::new(), Vec::new()) } }; @@ -413,21 +406,20 @@ impl GroupsRegistry { if let Err(error) = this.update(cx, |this, cx| { this.absorb_lists(lists, cx); }) { - log::warn!("nip29: applying the cached group lists failed: {error}"); + log::warn!("applying the cached group lists failed: {error}"); return; } - // Show the cached metadata before the relays answer. let previews = match this.update(cx, |this, cx| this.load_cached_previews(cx)) { Ok(task) => match task.await { Ok(previews) => previews, Err(error) => { - log::warn!("nip29: loading the cached group metadata failed: {error}"); + log::warn!("loading the cached group metadata failed: {error}"); Vec::new() } }, Err(error) => { - log::warn!("nip29: discovering the groups failed: {error}"); + log::warn!("discovering the groups failed: {error}"); return; } }; @@ -437,12 +429,12 @@ impl GroupsRegistry { Ok(task) => match task.await { Ok(members) => members, Err(error) => { - log::warn!("nip29: loading the cached group members failed: {error}"); + log::warn!("loading the cached group members failed: {error}"); Vec::new() } }, Err(error) => { - log::warn!("nip29: discovering the groups failed: {error}"); + log::warn!("discovering the groups failed: {error}"); return; } }; @@ -451,16 +443,14 @@ impl GroupsRegistry { this.absorb_cached_previews(previews, cx); this.absorb_cached_members(members, cx); }) { - log::warn!("nip29: discovering the groups failed: {error}"); + log::warn!("discovering the groups failed: {error}"); return; } - // Reconcile the contacts' group lists with the pool relays: only - // the events missing locally are downloaded and stored. let lists = match this.update(cx, |this, cx| this.sync_lists(authors, cx)) { Ok(task) => task, Err(error) => { - log::warn!("nip29: discovering the groups failed: {error}"); + log::warn!("discovering the groups failed: {error}"); return; } }; @@ -469,7 +459,7 @@ impl GroupsRegistry { let sweep = match this.update(cx, |this, cx| this.sync_sweep(cx)) { Ok(task) => task, Err(error) => { - log::warn!("nip29: discovering the groups failed: {error}"); + log::warn!("discovering the groups failed: {error}"); return; } }; @@ -477,7 +467,7 @@ impl GroupsRegistry { let lists = match lists.await { Ok(lists) => lists, Err(error) => { - log::warn!("nip29: syncing the contacts group lists failed: {error}"); + log::warn!("syncing the contacts group lists failed: {error}"); Vec::new() } }; @@ -485,13 +475,13 @@ impl GroupsRegistry { if let Err(error) = this.update(cx, |this, cx| { this.absorb_lists(lists, cx); }) { - log::warn!("nip29: applying the contacts group lists failed: {error}"); + log::warn!("applying the contacts group lists failed: {error}"); } let sweep = match sweep.await { Ok(events) => events, Err(error) => { - log::warn!("nip29: syncing the discovered groups failed: {error}"); + log::warn!("syncing the discovered groups failed: {error}"); Vec::new() } }; @@ -501,7 +491,7 @@ impl GroupsRegistry { this.absorb_discovered(relay, &event, cx); } }) { - log::warn!("nip29: applying the discovered groups failed: {error}"); + log::warn!("applying the discovered groups failed: {error}"); } // The previews cover the relays the lists just referenced. @@ -509,12 +499,12 @@ impl GroupsRegistry { Ok(task) => match task.await { Ok(previews) => previews, Err(error) => { - log::warn!("nip29: syncing the group previews failed: {error}"); + log::warn!("syncing the group previews failed: {error}"); Vec::new() } }, Err(error) => { - log::warn!("nip29: discovering the groups failed: {error}"); + log::warn!("discovering the groups failed: {error}"); return; } }; @@ -527,7 +517,7 @@ impl GroupsRegistry { this.discovery .update(cx, |discovery, cx| discovery.settle(cx)); }) { - log::warn!("nip29: discovering the groups failed: {error}"); + log::warn!("discovering the groups failed: {error}"); } })); } @@ -560,7 +550,7 @@ impl GroupsRegistry { let metadata = match GroupMetadata::parse(&event) { Ok(metadata) => metadata, Err(error) => { - log::warn!("nip29: ignoring malformed cached group metadata: {error}"); + log::warn!("ignoring malformed cached group metadata: {error}"); return newest; } }; @@ -717,7 +707,7 @@ impl GroupsRegistry { let metadata = match GroupMetadata::parse(event) { Ok(metadata) => metadata, Err(error) => { - log::warn!("nip29: ignoring malformed group metadata from {relay}: {error}"); + log::warn!("ignoring malformed group metadata from {relay}: {error}"); return; } }; @@ -762,7 +752,7 @@ impl GroupsRegistry { async move { if let Err(error) = client.add_relay(relay.clone()).and_connect().await { - log::warn!("nip29: connecting to {relay} failed: {error}"); + log::warn!("connecting to {relay} failed: {error}"); } let filter = Filter::new() @@ -811,10 +801,7 @@ impl GroupsRegistry { fn absorb_group_list(&mut self, event: &Event, cx: &mut Context) { let Ok(list) = GroupList::parse(event) else { - log::warn!( - "nip29: ignoring a malformed group list from {}", - event.pubkey - ); + log::warn!("ignoring a malformed group list from {}", event.pubkey); return; }; @@ -861,7 +848,7 @@ impl GroupsRegistry { let metadata = match GroupMetadata::parse(event) { Ok(metadata) => metadata, Err(error) => { - log::warn!("nip29: ignoring malformed group metadata from {relay}: {error}"); + log::warn!("ignoring malformed group metadata from {relay}: {error}"); return; } }; @@ -925,7 +912,7 @@ impl GroupsRegistry { self.tasks.push(cx.spawn(async move |this, cx| { if let Err(error) = sent.await { - log::warn!("nip29: leaving {} failed: {error}", key.id()); + log::warn!("leaving {} failed: {error}", key.id()); return; } this.update(cx, |this, cx| { @@ -963,7 +950,7 @@ impl GroupsRegistry { self.tasks.push(cx.background_spawn(async move { for id in ids { if let Err(error) = client.unsubscribe(&id).await { - log::warn!("nip29: unsubscribing {id} failed: {error}"); + log::warn!("unsubscribing {id} failed: {error}"); } } })); @@ -995,11 +982,14 @@ impl GroupsRegistry { self.tasks .push(cx.spawn(async move |this, cx| match task.await { Ok(list) => { - if let Err(error) = this.update(cx, |this, cx| this.merge(list, cx)) { - log::warn!("nip29: applying the cached group list failed: {error}"); - } + this.update(cx, |this, cx| { + this.merge(list, cx); + }) + .ok(); + } + Err(error) => { + log::warn!("loading the cached group list failed: {error}"); } - Err(error) => log::warn!("nip29: loading the cached group list failed: {error}"), })); } @@ -1008,7 +998,7 @@ impl GroupsRegistry { let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); - let updates: Task> = cx.background_spawn(async move { + cx.background_spawn(async move { let me = signer.get_public_key_async().await?; let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1); @@ -1019,14 +1009,9 @@ impl GroupsRegistry { .close_on(opts) .await?; - Ok(()) - }); - - self.tasks.push(cx.spawn(async move |_this, _cx| { - if let Err(error) = updates.await { - log::warn!("nip29: subscribing to the group list failed: {error}"); - } - })); + Ok::<(), anyhow::Error>(()) + }) + .detach(); self.watch_forks(cx); } @@ -1050,7 +1035,6 @@ impl GroupsRegistry { self.tasks.push(cx.background_spawn(async move { loop { executor.timer(FORK_INTERVAL).await; - if tx.send_async(Signal::Forks).await.is_err() { break; } @@ -1091,14 +1075,14 @@ impl GroupsRegistry { .with_id(fork_subscription_id()) .await { - log::warn!("nip29: subscribing to the admins' group lists failed: {error}"); + log::warn!("subscribing to the admins' group lists failed: {error}"); } })); } fn absorb_fork_list(&mut self, event: &Event, cx: &mut Context) { let Ok(list) = GroupList::parse(event) else { - log::warn!("nip29: ignoring a malformed admin group list"); + log::warn!("ignoring a malformed admin group list"); return; }; @@ -1201,7 +1185,7 @@ impl GroupsRegistry { self.tasks.push(cx.background_spawn(async move { for id in [id, page] { if let Err(error) = client.unsubscribe(&id).await { - log::warn!("nip29: unsubscribing {id} failed: {error}"); + log::warn!("unsubscribing {id} failed: {error}"); } } @@ -1210,7 +1194,7 @@ impl GroupsRegistry { .custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag); if let Err(error) = client.database().delete(filter).await { - log::warn!("nip29: clearing the group cache failed: {error}"); + log::warn!("clearing the group cache failed: {error}"); } })); @@ -1271,18 +1255,17 @@ impl GroupsRegistry { let event = match builder.finalize_async(&signer).await { Ok(event) => event, Err(error) => { - log::warn!("nip29: signing the group list failed: {error}"); + log::warn!("signing the group list failed: {error}"); return; } }; match client.send_event(&event).to_nip65().await { Ok(output) if output.failed.is_empty() => {} - Ok(output) => log::warn!( - "nip29: {} relay(s) rejected the group list", - output.failed.len() - ), - Err(error) => log::warn!("nip29: publishing the group list failed: {error}"), + Ok(output) => { + log::warn!("{} relay(s) rejected the group list", output.failed.len()) + } + Err(error) => log::warn!("publishing the group list failed: {error}"), } })); } @@ -1307,7 +1290,7 @@ impl GroupsRegistry { .read_with(cx, |group, cx| group.save_envelope(event, cx)) .await { - log::warn!("nip29: caching failed: {error}"); + log::warn!("caching failed: {error}"); return; } @@ -1318,7 +1301,7 @@ impl GroupsRegistry { }); } Err(error) => { - log::warn!("nip29: reloading {} failed: {error}", logged.id()); + log::warn!("reloading {} failed: {error}", logged.id()); } } })); @@ -1345,7 +1328,7 @@ impl GroupsRegistry { }); } Err(error) => { - log::warn!("nip29: loading {} state failed: {error}", logged.id()); + log::warn!("loading {} state failed: {error}", logged.id()); } })); } @@ -1365,7 +1348,7 @@ impl GroupsRegistry { .read_with(cx, |group, cx| group.save_envelope(event.clone(), cx)) .await { - log::warn!("nip29: caching failed: {error}"); + log::warn!("caching failed: {error}"); return; } @@ -1411,7 +1394,7 @@ impl GroupsRegistry { self.tasks.push(cx.spawn(async move |this, cx| { if let Err(error) = purge.await { - log::warn!("nip29: dropping the deleted envelope failed: {error}"); + log::warn!("dropping the deleted envelope failed: {error}"); } this.update(cx, |this, cx| { group.update(cx, |group, cx| { @@ -1427,7 +1410,7 @@ impl GroupsRegistry { if id == list_subscription_id() { match GroupList::parse(&event) { Ok(list) => self.merge(list, cx), - Err(error) => log::warn!("nip29: ignoring a malformed group list: {error}"), + Err(error) => log::warn!("ignoring a malformed group list: {error}"), } return; } @@ -1470,7 +1453,7 @@ impl GroupsRegistry { } let Ok(metadata) = GroupMetadata::parse(event) else { - log::warn!("nip29: ignoring malformed group metadata from {relay}"); + log::warn!("ignoring malformed group metadata from {relay}"); return; }; @@ -1515,12 +1498,12 @@ impl GroupsRegistry { fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context) { if let Some(relay) = self.browse.get(&id) { - log::warn!("nip29: browsing {relay} was refused: {reason}"); + log::warn!("browsing {relay} was refused: {reason}"); return; } if id == fork_subscription_id() { - log::debug!("nip29: the admins' group lists were refused: {reason}"); + log::debug!("the admins' group lists were refused: {reason}"); return; } @@ -1531,7 +1514,7 @@ impl GroupsRegistry { if is_page(&id) { self.routes.remove(&id); log::warn!( - "nip29: loading older messages in {} was refused: {reason}", + "loading older messages in {} was refused: {reason}", key.id() ); diff --git a/crates/workspace/src/panels/browse.rs b/crates/workspace/src/panels/browse.rs index 772889e7..9fd0b4f3 100644 --- a/crates/workspace/src/panels/browse.rs +++ b/crates/workspace/src/panels/browse.rs @@ -16,7 +16,6 @@ use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; -use ui::indicator::Indicator; use ui::input::{Input, InputEvent, InputState}; use ui::notification::Notification; use ui::scroll::Scrollbar; @@ -214,10 +213,6 @@ impl BrowsePanel { self.render_card_rows(cards, cx) } - /// A row holds two group cards side by side. - /// - /// The cards are the relative slice of the visible rows, so they pair up - /// in order, two per row. fn render_card_rows( &self, cards: &[GroupCandidate], @@ -235,8 +230,6 @@ impl BrowsePanel { .p_2() .gap_2() .children(first.map(|card| self.render_card(card, cx))) - // Keep an odd last card in one column: an empty spacer - // takes the second slot so the card cannot stretch. .child(match second { Some(card) => self.render_card(card, cx), None => div().flex_1().into_any_element(), @@ -339,7 +332,7 @@ impl BrowsePanel { .text_sm() .text_color(cx.theme().text_muted) .text_ellipsis() - .line_clamp(3) + .line_clamp(2) .map(|this| { if let Some(about) = about { this.child(about) @@ -458,28 +451,31 @@ impl Render for BrowsePanel { v_flex() .size_full() .child( - h_flex() - .gap_2() - .px_2() + v_flex() + .h_32() .flex_shrink_0() - .child(Input::new(&self.input).cleanable(true).flex_1()) + .items_center() + .justify_center() + .bg(cx.theme().surface_background) .child( - Button::new("browse-go") - .label("Go") - .primary() - .on_click(cx.listener(|this, _event, window, cx| { - this.browse(window, cx); - })), - ) - .when(self.loading || (discover && discovering), |this| { - this.child(Indicator::new().small().color(cx.theme().icon_accent)) - }), + h_flex() + .w_full() + .max_w_56() + .gap_1() + .child(Input::new(&self.input).cleanable(true).flex_1()) + .child(Button::new("browse-go").label("Go").primary().on_click( + cx.listener(|this, _event, window, cx| { + this.browse(window, cx); + }), + )), + ), ) .child( div() .flex_1() .min_h_0() .relative() + .px_2() .child(body) .child(Scrollbar::vertical(&self.scroll_handle)), ) -- 2.54.0 From 64b49443ff946f147963f16e78a38424eed466fa Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 7 Oct 2026 17:20:40 +0700 Subject: [PATCH 06/10] Replace relay subscriptions with cache-backed discovery sync --- crates/nip29/src/discovery.rs | 305 ++++++++--- crates/nip29/src/lib.rs | 737 +++----------------------- crates/nip29/src/utils.rs | 60 +++ crates/workspace/src/panels/browse.rs | 114 +++- 4 files changed, 462 insertions(+), 754 deletions(-) create mode 100644 crates/nip29/src/utils.rs diff --git a/crates/nip29/src/discovery.rs b/crates/nip29/src/discovery.rs index a1074338..18d834b1 100644 --- a/crates/nip29/src/discovery.rs +++ b/crates/nip29/src/discovery.rs @@ -1,71 +1,27 @@ use std::collections::{HashMap, HashSet}; -use gpui::Context; +use futures::future::join_all; use nostr_sdk::prelude::*; -use crate::protocol::{GroupCandidate, GroupId, GroupKey, GroupList}; +use crate::protocol::{GroupCandidate, GroupId, GroupKey, GroupList, GroupMetadata}; +use crate::utils::parse_members; -/// The groups discovered from the cache and the relays. -#[derive(Default)] +#[derive(Default, Clone, Debug)] pub struct Discovery { refs: HashMap>, discovered: Vec, members: HashMap, - loading: bool, } impl Discovery { + /// The discovered groups, sorted by name. pub fn discovered(&self) -> &[GroupCandidate] { &self.discovered } - pub fn loading(&self) -> bool { - self.loading - } - - /// The discovery is starting over: nothing has answered yet. - pub(crate) fn begin(&mut self, cx: &mut Context) { - self.loading = true; - cx.notify(); - } - - /// Every pending discovery step has finished. - pub(crate) fn settle(&mut self, cx: &mut Context) { - self.loading = false; - cx.notify(); - } - - /// Absorb the references of a group list, skipping the joined groups. - /// - /// Returns whether new references were added. - pub(crate) fn absorb_list( - &mut self, - list: &GroupList, - joined: &HashSet<(RelayUrl, GroupId)>, - cx: &mut Context, - ) -> bool { - let mut added = false; - - for entry in list.entries() { - if joined.contains(&(entry.relay.clone(), entry.id.clone())) { - continue; - } - - if self - .refs - .entry(entry.relay.clone()) - .or_default() - .insert(entry.id.clone()) - { - added = true; - } - } - - if added { - cx.notify(); - } - - added + /// The member count of a group, when its member list is known. + pub fn members(&self, key: &GroupKey) -> Option { + self.members.get(key).map(|(_, members)| *members) } /// The (relay, group) references, for asking the relays for metadata. @@ -73,8 +29,49 @@ impl Discovery { &self.refs } + /// Absorb the references of a group list, skipping the joined groups. + pub(crate) fn absorb_list(&mut self, list: &GroupList, joined: &HashSet<(RelayUrl, GroupId)>) { + for entry in list.entries() { + if joined.contains(&(entry.relay.clone(), entry.id.clone())) { + continue; + } + + self.refs + .entry(entry.relay.clone()) + .or_default() + .insert(entry.id.clone()); + } + } + + /// Absorb the newest group list of every author. + pub(crate) fn absorb_lists( + &mut self, + lists: Vec, + joined: &HashSet<(RelayUrl, GroupId)>, + ) { + let newest: HashMap = + lists.into_iter().fold(HashMap::new(), |mut newest, event| { + match newest.get(&event.pubkey) { + Some(current) if current.created_at >= event.created_at => newest, + _ => { + newest.insert(event.pubkey, event); + newest + } + } + }); + + for event in newest.into_values() { + let Ok(list) = GroupList::parse(&event) else { + log::warn!("ignoring a malformed group list from {}", event.pubkey); + continue; + }; + + self.absorb_list(&list, joined); + } + } + /// Add a discovered group, keeping the newest metadata. - pub(crate) fn upsert(&mut self, candidate: GroupCandidate, cx: &mut Context) { + pub(crate) fn upsert(&mut self, candidate: GroupCandidate) { match self.discovered.iter_mut().find(|d| d.key == candidate.key) { Some(existing) if existing.created_at <= candidate.created_at => { *existing = candidate; @@ -88,18 +85,10 @@ impl Discovery { .to_lowercase() .cmp(&b.display_name().to_lowercase()) }); - - cx.notify(); } /// Apply a member count, keeping the newest list. - pub(crate) fn set_members( - &mut self, - key: GroupKey, - created_at: Timestamp, - members: usize, - cx: &mut Context, - ) { + pub fn set_members(&mut self, key: GroupKey, created_at: Timestamp, members: usize) { if self .members .get(&key) @@ -108,21 +97,191 @@ impl Discovery { return; } - if self.members.insert(key, (created_at, members)) != Some((created_at, members)) { - cx.notify(); + self.members.insert(key, (created_at, members)); + } + + /// Seed the discoveries from the cached group metadata. + pub(crate) fn absorb_cached_previews(&mut self, previews: Vec) { + let newest: HashMap = + previews + .into_iter() + .fold(HashMap::new(), |mut newest, event| { + let metadata = match GroupMetadata::parse(&event) { + Ok(metadata) => metadata, + Err(error) => { + log::warn!("ignoring malformed cached group metadata: {error}"); + return newest; + } + }; + + let Some(id) = metadata.id().cloned() else { + return newest; + }; + + match newest.get(&id) { + Some((created_at, _)) if *created_at >= event.created_at => newest, + _ => { + newest.insert(id, (event.created_at, metadata)); + newest + } + } + }); + + for (relay, ids) in self.refs.clone() { + for id in ids { + let Some((created_at, metadata)) = newest.get(&id) else { + continue; + }; + + // Hidden groups are not discoverable (NIP-29). + if metadata.hidden { + continue; + } + + self.upsert(GroupCandidate { + key: GroupKey::new(relay.clone(), id.clone()), + created_at: *created_at, + metadata: metadata.clone(), + }); + } } } - /// The member count of a group, when its member list is known. - pub fn members(&self, key: &GroupKey) -> Option { - self.members.get(key).map(|(_, members)| *members) + /// Seed the member counts from the cached member lists. + pub(crate) fn absorb_cached_members(&mut self, members: Vec) { + let newest: HashMap = + members + .into_iter() + .fold(HashMap::new(), |mut newest, event| { + let Some((id, created_at, count)) = parse_members(&event) else { + return newest; + }; + + match newest.get(&id) { + Some((current, _)) if *current >= created_at => newest, + _ => { + newest.insert(id, (created_at, count)); + newest + } + } + }); + + for (relay, ids) in self.refs.clone() { + for id in ids { + let Some((created_at, count)) = newest.get(&id) else { + continue; + }; + + self.set_members( + GroupKey::new(relay.clone(), id.clone()), + *created_at, + *count, + ); + } + } } - pub(crate) fn reset(&mut self, cx: &mut Context) { - self.refs.clear(); - self.discovered.clear(); - self.members.clear(); - self.loading = false; - cx.notify(); + /// Load the discoverable groups from the local database only. + pub async fn cached(client: Client, joined: HashSet<(RelayUrl, GroupId)>) -> Self { + let mut discovery = Self::default(); + + // The group lists of every cached author. + let lists: Vec = client + .database() + .query(Filter::new().kind(Kind::SimpleGroups)) + .await + .map(|events| events.into_iter().collect()) + .unwrap_or_default(); + + discovery.absorb_lists(lists, &joined); + + let refs = discovery.refs().clone(); + + if refs.is_empty() { + return discovery; + } + + let ids: HashSet<&str> = refs.values().flatten().map(GroupId::as_str).collect(); + + // The cached metadata of the referenced groups. + let previews: Vec = client + .database() + .query( + Filter::new() + .kind(Kind::GroupMetadata) + .identifiers(ids.clone()), + ) + .await + .map(|events| events.into_iter().collect()) + .unwrap_or_default(); + + discovery.absorb_cached_previews(previews); + + // The cached member lists of the referenced groups. + let members: Vec = client + .database() + .query(Filter::new().kind(Kind::GroupMembers).identifiers(ids)) + .await + .map(|events| events.into_iter().collect()) + .unwrap_or_default(); + + discovery.absorb_cached_members(members); + + discovery + } + + /// Reconcile the discoverable groups with the relays. + pub async fn sync( + &mut self, + client: Client, + me: PublicKey, + joined: HashSet<(RelayUrl, GroupId)>, + ) { + let filter = Filter::new().author(me).kind(Kind::ContactList).limit(1); + + let authors: HashSet = client + .database() + .query(filter) + .await + .ok() + .and_then(|events| events.into_iter().next()) + .map(|event| event.tags.public_keys().collect()) + .unwrap_or_default(); + + let urls: Vec = client.relays().await.into_keys().collect(); + let filter = Filter::new().authors(authors).kind(Kind::SimpleGroups); + client.sync(filter.clone()).with(urls).await.ok(); + + let lists: Vec = client + .database() + .query(filter) + .await + .map(|events| events.into_iter().collect()) + .unwrap_or_default(); + + self.absorb_lists(lists, &joined); + + // The pool-wide sweep of the group metadata and members. + let urls: Vec = client.relays().await.into_keys().collect(); + let filter = Filter::new().kinds([Kind::GroupMetadata, Kind::GroupMembers]); + client.sync(filter).with(urls).await.ok(); + + // The previews cover the relays the lists just referenced. + let fetches = self.refs.clone().into_iter().map(|(relay, ids)| { + let client = client.clone(); + + async move { + client.add_relay(relay.clone()).and_connect().await.ok(); + + let filter = Filter::new() + .kinds([Kind::GroupMetadata, Kind::GroupMembers]) + .identifiers(ids.iter().map(GroupId::as_str)); + + let urls = vec![relay]; + client.sync(filter).with(urls).await.ok(); + } + }); + + join_all(fetches).await; } } diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index 393706f0..ad019b6e 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -1,6 +1,4 @@ -use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; -use std::hash::{Hash, Hasher}; use std::sync::LazyLock; use std::time::Duration; @@ -13,18 +11,15 @@ use state::NostrRegistry; mod discovery; mod group; mod protocol; +mod utils; pub use discovery::Discovery; pub use group::*; pub use protocol::*; +pub(crate) use utils::*; pub(crate) static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); -const LIST_SUBSCRIPTION: &str = "nip29-list"; -const FORK_SUBSCRIPTION: &str = "nip29-forks"; -const GROUP_PREFIX: &str = "grp-"; -const BROWSE_PREFIX: &str = "brw-"; -const PAGE_PREFIX: &str = "pg-"; const BROWSE_LIMIT: usize = 500; const BROWSE_TIMEOUT: Duration = Duration::from_secs(10); const FORK_INTERVAL: Duration = Duration::from_secs(15 * 60); @@ -43,6 +38,14 @@ pub enum GroupsEvent { Error(String), Browsed(Box), BrowseSettled(RelayUrl), + /// A browsed group announced its member list. + Members { + key: GroupKey, + created_at: Timestamp, + members: usize, + }, + /// The discovery synced new groups into the local database. + Synced, } #[derive(Debug)] @@ -51,105 +54,13 @@ enum Signal { Eose(SubscriptionId), Closed(SubscriptionId, String), BrowseSettled(SubscriptionId), + /// A reconciliation started on a relay. + Syncing(RelayUrl), + /// A relay finished answering a reconciliation. + Synced(RelayUrl), Forks, } -fn list_subscription_id() -> SubscriptionId { - SubscriptionId::new(LIST_SUBSCRIPTION) -} - -fn fork_subscription_id() -> SubscriptionId { - SubscriptionId::new(FORK_SUBSCRIPTION) -} - -fn subscription_id(key: &GroupKey) -> SubscriptionId { - SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32)) -} - -fn browse_subscription(relay: &RelayUrl) -> SubscriptionId { - let mut hasher = DefaultHasher::new(); - relay.hash(&mut hasher); - - SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32)) -} - -fn page_subscription(key: &GroupKey) -> SubscriptionId { - SubscriptionId::new(format!("{PAGE_PREFIX}{:08x}", key.uniq_id() as u32)) -} - -fn is_page(id: &SubscriptionId) -> bool { - id.as_str().starts_with(PAGE_PREFIX) -} - -fn is_own_subscription(id: &SubscriptionId) -> bool { - let id = id.as_str(); - id == LIST_SUBSCRIPTION - || id == FORK_SUBSCRIPTION - || id.starts_with(GROUP_PREFIX) - || id.starts_with(BROWSE_PREFIX) - || id.starts_with(PAGE_PREFIX) -} - -fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize)> { - if event.kind != Kind::GroupMembers { - return None; - } - - let id = GroupId::new(event.tags.identifier()?).ok()?; - let members = event.tags.public_keys().collect::>().len(); - - Some((id, event.created_at, members)) -} - -async fn reconcile( - client: &Client, - filter: Filter, - urls: Vec, -) -> HashMap> { - let output = match client.sync(filter).with(urls).await { - Ok(output) => output, - Err(error) => { - log::warn!("the reconciliation failed: {error}"); - return HashMap::new(); - } - }; - - for (relay, error) in &output.failed { - log::debug!("syncing with {relay} failed: {error}"); - } - - output.value.received -} - -/// Pair the downloaded events with the relay that served them. -async fn pull_received( - client: &Client, - received: HashMap>, -) -> Result, Error> { - if received.is_empty() { - return Ok(Vec::new()); - } - - let events: HashMap = client - .database() - .query(Filter::new().ids(received.keys().cloned())) - .await? - .into_iter() - .map(|event| (event.id, event)) - .collect(); - - Ok(received - .into_iter() - .flat_map(|(id, relays)| match events.get(&id) { - Some(event) => relays - .into_iter() - .map(|relay| (relay, event.clone())) - .collect(), - None => Vec::new(), - }) - .collect()) -} - impl EventEmitter for GroupsRegistry {} pub struct GroupsRegistry { @@ -160,8 +71,8 @@ pub struct GroupsRegistry { observers: HashMap, forks: HashMap, fork_authors: HashSet, - /// The groups discovered from the cache and the relays. - discovery: Entity, + /// The relays with a discovery reconciliation in flight. + syncing: HashSet, signal_tx: flume::Sender, signal_rx: flume::Receiver, notification_listener: Option>, @@ -215,7 +126,7 @@ impl GroupsRegistry { observers: HashMap::new(), forks: HashMap::new(), fork_authors: HashSet::new(), - discovery: cx.new(|_| Discovery::default()), + syncing: HashSet::new(), signal_tx: tx, signal_rx: rx, notification_listener: None, @@ -346,543 +257,42 @@ impl GroupsRegistry { cx.emit(GroupsEvent::BrowseSettled(relay)); } - /// The groups discovered from the cache and the relays. - pub fn discovery(&self) -> Entity { - self.discovery.clone() - } - - pub fn discover(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - - if nostr.read(cx).current_user().is_none() { - // There is nothing to discover without a user. - self.discovery.update(cx, |discovery, cx| { - discovery.settle(cx); - }); - return; - } - - // Every open sweeps the relays again, starting from the cache. - self.discovery.update(cx, |discovery, cx| { - discovery.begin(cx); - }); - - type Cached = (HashSet, Vec); - - let task: Task> = cx.background_spawn(async move { - let me = signer.get_public_key_async().await?; - let filter = Filter::new().author(me).kind(Kind::ContactList).limit(1); - - let contacts = client - .database() - .query(filter) - .await? - .into_iter() - .next() - .map(|event| event.tags.public_keys().collect()) - .unwrap_or_default(); - - let lists = client - .database() - .query(Filter::new().kind(Kind::SimpleGroups)) - .await? - .into_iter() - .collect(); - - Ok((contacts, lists)) - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - let (authors, lists) = match task.await { - Ok(ok) => ok, - Err(error) => { - log::warn!("loading the cached groups failed: {error}"); - (HashSet::new(), Vec::new()) - } - }; - - if let Err(error) = this.update(cx, |this, cx| { - this.absorb_lists(lists, cx); - }) { - log::warn!("applying the cached group lists failed: {error}"); - return; - } - - let previews = match this.update(cx, |this, cx| this.load_cached_previews(cx)) { - Ok(task) => match task.await { - Ok(previews) => previews, - Err(error) => { - log::warn!("loading the cached group metadata failed: {error}"); - Vec::new() - } - }, - Err(error) => { - log::warn!("discovering the groups failed: {error}"); - return; - } - }; - - // Cached member lists fill the cards' member counts. - let members = match this.update(cx, |this, cx| this.load_cached_members(cx)) { - Ok(task) => match task.await { - Ok(members) => members, - Err(error) => { - log::warn!("loading the cached group members failed: {error}"); - Vec::new() - } - }, - Err(error) => { - log::warn!("discovering the groups failed: {error}"); - return; - } - }; - - if let Err(error) = this.update(cx, |this, cx| { - this.absorb_cached_previews(previews, cx); - this.absorb_cached_members(members, cx); - }) { - log::warn!("discovering the groups failed: {error}"); - return; - } - - let lists = match this.update(cx, |this, cx| this.sync_lists(authors, cx)) { - Ok(task) => task, - Err(error) => { - log::warn!("discovering the groups failed: {error}"); - return; - } - }; - - // The pool-wide sweep runs while the lists are reconciling. - let sweep = match this.update(cx, |this, cx| this.sync_sweep(cx)) { - Ok(task) => task, - Err(error) => { - log::warn!("discovering the groups failed: {error}"); - return; - } - }; - - let lists = match lists.await { - Ok(lists) => lists, - Err(error) => { - log::warn!("syncing the contacts group lists failed: {error}"); - Vec::new() - } - }; - - if let Err(error) = this.update(cx, |this, cx| { - this.absorb_lists(lists, cx); - }) { - log::warn!("applying the contacts group lists failed: {error}"); - } - - let sweep = match sweep.await { - Ok(events) => events, - Err(error) => { - log::warn!("syncing the discovered groups failed: {error}"); - Vec::new() - } - }; - - if let Err(error) = this.update(cx, |this, cx| { - for (relay, event) in sweep { - this.absorb_discovered(relay, &event, cx); - } - }) { - log::warn!("applying the discovered groups failed: {error}"); - } - - // The previews cover the relays the lists just referenced. - let previews = match this.update(cx, |this, cx| this.sync_previews(cx)) { - Ok(task) => match task.await { - Ok(previews) => previews, - Err(error) => { - log::warn!("syncing the group previews failed: {error}"); - Vec::new() - } - }, - Err(error) => { - log::warn!("discovering the groups failed: {error}"); - return; - } - }; - - if let Err(error) = this.update(cx, |this, cx| { - for (relay, event) in previews { - this.absorb_preview(relay, &event, cx); - } - - this.discovery - .update(cx, |discovery, cx| discovery.settle(cx)); - }) { - log::warn!("discovering the groups failed: {error}"); - } - })); - } - - /// Load the cached metadata for the referenced groups from the database. - fn load_cached_previews(&self, cx: &mut Context) -> Task, Error>> { - let refs = self.discovery.read(cx).refs().clone(); - - if refs.is_empty() { - return Task::ready(Ok(Vec::new())); - } - - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let ids: HashSet<&str> = refs.values().flatten().map(GroupId::as_str).collect(); - let filter = Filter::new().kind(Kind::GroupMetadata).identifiers(ids); - - cx.background_spawn(async move { - Ok(client.database().query(filter).await?.into_iter().collect()) - }) - } - - /// Seed the discoveries from the cached group metadata. - fn absorb_cached_previews(&mut self, previews: Vec, cx: &mut Context) { - let newest: HashMap = - previews - .into_iter() - .fold(HashMap::new(), |mut newest, event| { - let metadata = match GroupMetadata::parse(&event) { - Ok(metadata) => metadata, - Err(error) => { - log::warn!("ignoring malformed cached group metadata: {error}"); - return newest; - } - }; - - let Some(id) = metadata.id().cloned() else { - return newest; - }; - - match newest.get(&id) { - Some((created_at, _)) if *created_at >= event.created_at => newest, - _ => { - newest.insert(id, (event.created_at, metadata)); - newest - } - } - }); - - let mut candidates = Vec::new(); - - for (relay, ids) in self.discovery.read(cx).refs().clone() { - for id in ids { - let Some((created_at, metadata)) = newest.get(&id) else { - continue; - }; - - // Hidden groups are not discoverable (NIP-29). - if metadata.hidden { - continue; - } - - candidates.push(GroupCandidate { - key: GroupKey::new(relay.clone(), id.clone()), - created_at: *created_at, - metadata: metadata.clone(), - }); - } - } - - self.discovery.update(cx, |discovery, cx| { - for candidate in candidates { - discovery.upsert(candidate, cx); - } - }); - } - - /// Load the cached member lists for the referenced groups from the database. - fn load_cached_members(&self, cx: &mut Context) -> Task, Error>> { - let refs = self.discovery.read(cx).refs().clone(); - - if refs.is_empty() { - return Task::ready(Ok(Vec::new())); - } - - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let ids: HashSet<&str> = refs.values().flatten().map(GroupId::as_str).collect(); - let filter = Filter::new().kind(Kind::GroupMembers).identifiers(ids); - - cx.background_spawn(async move { - Ok(client.database().query(filter).await?.into_iter().collect()) - }) - } - - /// Seed the member counts from the cached member lists. - fn absorb_cached_members(&mut self, members: Vec, cx: &mut Context) { - let newest: HashMap = - members - .into_iter() - .fold(HashMap::new(), |mut newest, event| { - let Some((id, created_at, count)) = parse_members(&event) else { - return newest; - }; - - match newest.get(&id) { - Some((current, _)) if *current >= created_at => newest, - _ => { - newest.insert(id, (created_at, count)); - newest - } - } - }); - - for (relay, ids) in self.discovery.read(cx).refs().clone() { - for id in ids { - let Some((created_at, count)) = newest.get(&id) else { - continue; - }; - - let created_at = *created_at; - let count = *count; - - self.discovery.update(cx, |discovery, cx| { - discovery.set_members( - GroupKey::new(relay.clone(), id.clone()), - created_at, - count, - cx, - ); - }); - } - } - } - - /// Reconcile the contacts' group lists with the pool relays, returning - /// the newest lists now stored in the database. - fn sync_lists( - &self, - authors: HashSet, - cx: &mut Context, - ) -> Task, Error>> { - if authors.is_empty() { - return Task::ready(Ok(Vec::new())); - } - - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let filter = Filter::new().authors(authors).kind(Kind::SimpleGroups); - - cx.background_spawn(async move { - let urls: Vec = client.relays().await.into_keys().collect(); - reconcile(&client, filter.clone(), urls).await; - - Ok(client.database().query(filter).await?.into_iter().collect()) - }) - } - - /// Reconcile the groups of every pool relay, returning the newly - /// downloaded events with the relay that served them. - fn sync_sweep(&self, cx: &mut Context) -> Task, Error>> { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let filter = Filter::new().kinds([Kind::GroupMetadata, Kind::GroupMembers]); - - cx.background_spawn(async move { - let urls: Vec = client.relays().await.into_keys().collect(); - let received = reconcile(&client, filter, urls).await; - - pull_received(&client, received).await - }) - } - - /// Absorb a discovered group metadata event from the relay serving it. - fn absorb_discovered(&mut self, relay: RelayUrl, event: &Event, cx: &mut Context) { - if let Some((id, created_at, members)) = parse_members(event) { - self.discovery.update(cx, |discovery, cx| { - discovery.set_members(GroupKey::new(relay, id), created_at, members, cx); - }); - return; - } - - let metadata = match GroupMetadata::parse(event) { - Ok(metadata) => metadata, - Err(error) => { - log::warn!("ignoring malformed group metadata from {relay}: {error}"); - return; - } - }; - - // Hidden groups are not discoverable (NIP-29). - if metadata.hidden { - return; - } - - let Some(id) = metadata.id().cloned() else { - return; - }; - - self.discovery.update(cx, |discovery, cx| { - discovery.upsert( - GroupCandidate { - key: GroupKey::new(relay, id), - created_at: event.created_at, - metadata, - }, - cx, - ); - }); - } - - /// Reconcile the referenced groups with the relays hosting them, - /// returning the newly downloaded events with the relay that served - /// them. - fn sync_previews(&self, cx: &mut Context) -> Task, Error>> { - let refs = self.discovery.read(cx).refs().clone(); - - if refs.is_empty() { - return Task::ready(Ok(Vec::new())); - } - - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - cx.background_spawn(async move { - let fetches = refs.into_iter().map(|(relay, ids)| { - let client = client.clone(); - - async move { - if let Err(error) = client.add_relay(relay.clone()).and_connect().await { - log::warn!("connecting to {relay} failed: {error}"); - } - - let filter = Filter::new() - .kinds([Kind::GroupMetadata, Kind::GroupMembers]) - .identifiers(ids.iter().map(GroupId::as_str)); - - let urls = vec![relay.clone()]; - let received = reconcile(&client, filter, urls).await; - - (relay, received) - } - }); - - let fetched = futures::future::join_all(fetches).await; - let ids: HashSet = fetched - .iter() - .flat_map(|(_, received)| received.keys().cloned()) - .collect(); - - if ids.is_empty() { - return Ok(Vec::new()); - } - - let events: HashMap = client - .database() - .query(Filter::new().ids(ids)) - .await? - .into_iter() - .map(|event| (event.id, event)) - .collect(); - - Ok(fetched - .into_iter() - .flat_map(|(relay, received)| { - received - .into_keys() - .filter_map(|id| { - let event = events.get(&id)?; - Some((relay.clone(), event.clone())) - }) - .collect::>() - }) - .collect()) - }) - } - - fn absorb_group_list(&mut self, event: &Event, cx: &mut Context) { - let Ok(list) = GroupList::parse(event) else { - log::warn!("ignoring a malformed group list from {}", event.pubkey); - return; - }; - - let joined: HashSet<(RelayUrl, GroupId)> = self - .groups + /// The (relay, group) pairs of every joined group. + fn joined_keys(&self, cx: &App) -> HashSet<(RelayUrl, GroupId)> { + self.groups .iter() .map(|group| { let key = group.read(cx).key(); (key.relay().clone(), key.id().clone()) }) - .collect(); - - self.discovery.update(cx, |discovery, cx| { - discovery.absorb_list(&list, &joined, cx); - }); + .collect() } - /// Absorb the newest group list of every author. - fn absorb_lists(&mut self, lists: Vec, cx: &mut Context) { - let newest: HashMap = - lists.into_iter().fold(HashMap::new(), |mut newest, event| { - match newest.get(&event.pubkey) { - Some(current) if current.created_at >= event.created_at => newest, - _ => { - newest.insert(event.pubkey, event); - newest - } - } - }); + /// Load the discoverable groups from the local database only. + pub fn cached_discovery(&self, cx: &App) -> Task { + let client = NostrRegistry::global(cx).read(cx).client(); + let joined = self.joined_keys(cx); - for event in newest.into_values() { - self.absorb_group_list(&event, cx); - } + cx.background_spawn(Discovery::cached(client, joined)) } - fn absorb_preview(&mut self, relay: RelayUrl, event: &Event, cx: &mut Context) { - if let Some((group, created_at, members)) = parse_members(event) { - self.discovery.update(cx, |discovery, cx| { - discovery.set_members(GroupKey::new(relay, group), created_at, members, cx); - }); - return; + /// Reconcile the discoverable groups with the relays. + pub fn discover(&self, cx: &App) -> Task<()> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + let joined = self.joined_keys(cx); + + if nostr.read(cx).current_user().is_none() { + return Task::ready(()); } - let metadata = match GroupMetadata::parse(event) { - Ok(metadata) => metadata, - Err(error) => { - log::warn!("ignoring malformed group metadata from {relay}: {error}"); + cx.background_spawn(async move { + let Ok(me) = signer.get_public_key_async().await else { return; - } - }; - - // Hidden groups are not discoverable (NIP-29). - if metadata.hidden { - return; - } - - // A relay may answer with more than the reconciliation asked for. - let Some(group) = metadata.id().cloned() else { - return; - }; - - if !self - .discovery - .read(cx) - .refs() - .get(&relay) - .is_some_and(|ids| ids.contains(&group)) - { - return; - } - - self.discovery.update(cx, |discovery, cx| { - discovery.upsert( - GroupCandidate { - key: GroupKey::new(relay, group), - created_at: event.created_at, - metadata, - }, - cx, - ); - }); + }; + Discovery::default().sync(client, me, joined).await; + }) } pub fn load_more(&mut self, key: &GroupKey, cx: &mut Context) { @@ -933,6 +343,7 @@ impl GroupsRegistry { self.observers.clear(); self.forks.clear(); self.fork_authors.clear(); + self.syncing.clear(); self.list = GroupList::default(); let nostr = NostrRegistry::global(cx); @@ -944,8 +355,6 @@ impl GroupsRegistry { ids.extend(self.browse.keys().cloned()); self.browse.clear(); - self.discovery - .update(cx, |discovery, cx| discovery.reset(cx)); self.tasks.push(cx.background_spawn(async move { for id in ids { @@ -1446,8 +855,10 @@ impl GroupsRegistry { }; if let Some((group, created_at, members)) = parse_members(event) { - self.discovery.update(cx, |discovery, cx| { - discovery.set_members(GroupKey::new(relay, group), created_at, members, cx); + cx.emit(GroupsEvent::Members { + key: GroupKey::new(relay, group), + created_at, + members, }); return; } @@ -1554,44 +965,46 @@ impl GroupsRegistry { const MAX_PROCESSED: usize = 10_000; while let Some(notification) = notifications.next().await { - let ClientNotification::Message { message, .. } = notification else { + let ClientNotification::Message { relay_url, message } = notification else { continue; }; - let signal = match *message { + match *message { RelayMessage::Event { subscription_id, event, - } => { - let id = subscription_id.into_owned(); + } if is_own_subscription(&subscription_id) => { + if processed.len() >= MAX_PROCESSED { + processed.clear(); + } - if !is_own_subscription(&id) { - None - } else { - if processed.len() >= MAX_PROCESSED { - processed.clear(); - } - processed - .insert(event.id) - .then(|| Signal::Event(id, Box::new(event.into_owned()))) + if processed.insert(event.id) { + let signal = Signal::Event( + subscription_id.into_owned(), + Box::new(event.into_owned()), + ); + + tx.send_async(signal).await.ok(); } } - RelayMessage::EndOfStoredEvents(id) => { - let id = id.into_owned(); - is_own_subscription(&id).then_some(Signal::Eose(id)) + RelayMessage::EndOfStoredEvents(id) if is_own_subscription(&id) => { + tx.send_async(Signal::Eose(id.into_owned())).await.ok(); } RelayMessage::Closed { subscription_id, message, - } => { - let id = subscription_id.into_owned(); - is_own_subscription(&id).then_some(Signal::Closed(id, message.into_owned())) + } if is_own_subscription(&subscription_id) => { + let signal = + Signal::Closed(subscription_id.into_owned(), message.into_owned()); + tx.send_async(signal).await.ok(); } - _ => None, - }; - - if let Some(signal) = signal { - tx.send_async(signal).await.ok(); + RelayMessage::NegMsg { .. } => { + tx.send_async(Signal::Syncing(relay_url)).await.ok(); + } + RelayMessage::NegErr { .. } | RelayMessage::EndOfStoredEvents(_) => { + tx.send_async(Signal::Synced(relay_url)).await.ok(); + } + _ => {} } } })); @@ -1611,6 +1024,14 @@ impl GroupsRegistry { Signal::BrowseSettled(id) => { this.settle_browse(id, cx); } + Signal::Syncing(relay) => { + this.syncing.insert(relay); + } + Signal::Synced(relay) => { + if this.syncing.remove(&relay) { + cx.emit(GroupsEvent::Synced); + } + } Signal::Forks => { this.sync_forks(true, cx); } diff --git a/crates/nip29/src/utils.rs b/crates/nip29/src/utils.rs new file mode 100644 index 00000000..f66d4bc5 --- /dev/null +++ b/crates/nip29/src/utils.rs @@ -0,0 +1,60 @@ +use std::collections::HashSet; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use nostr_sdk::prelude::*; + +use crate::{GroupId, GroupKey}; + +const LIST_SUBSCRIPTION: &str = "nip29-list"; +const FORK_SUBSCRIPTION: &str = "nip29-forks"; +const GROUP_PREFIX: &str = "grp-"; +const BROWSE_PREFIX: &str = "brw-"; +const PAGE_PREFIX: &str = "pg-"; + +pub(crate) fn list_subscription_id() -> SubscriptionId { + SubscriptionId::new(LIST_SUBSCRIPTION) +} + +pub(crate) fn fork_subscription_id() -> SubscriptionId { + SubscriptionId::new(FORK_SUBSCRIPTION) +} + +pub(crate) fn subscription_id(key: &GroupKey) -> SubscriptionId { + SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32)) +} + +pub(crate) fn browse_subscription(relay: &RelayUrl) -> SubscriptionId { + let mut hasher = DefaultHasher::new(); + relay.hash(&mut hasher); + + SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32)) +} + +pub(crate) fn page_subscription(key: &GroupKey) -> SubscriptionId { + SubscriptionId::new(format!("{PAGE_PREFIX}{:08x}", key.uniq_id() as u32)) +} + +pub(crate) fn is_page(id: &SubscriptionId) -> bool { + id.as_str().starts_with(PAGE_PREFIX) +} + +pub(crate) fn is_own_subscription(id: &SubscriptionId) -> bool { + let id = id.as_str(); + id == LIST_SUBSCRIPTION + || id == FORK_SUBSCRIPTION + || id.starts_with(GROUP_PREFIX) + || id.starts_with(BROWSE_PREFIX) + || id.starts_with(PAGE_PREFIX) +} + +pub(crate) fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize)> { + if event.kind != Kind::GroupMembers { + return None; + } + + let id = GroupId::new(event.tags.identifier()?).ok()?; + let members = event.tags.public_keys().collect::>().len(); + + Some((id, event.created_at, members)) +} diff --git a/crates/workspace/src/panels/browse.rs b/crates/workspace/src/panels/browse.rs index 9fd0b4f3..e43030ed 100644 --- a/crates/workspace/src/panels/browse.rs +++ b/crates/workspace/src/panels/browse.rs @@ -4,12 +4,12 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - IntoElement, ParentElement, Render, ScrollStrategy, SharedString, Styled, Subscription, + IntoElement, ParentElement, Render, ScrollStrategy, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, px, relative, size, }; use gpui_base::{VirtualListScrollHandle, v_virtual_list}; use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry}; -use nostr_sdk::prelude::RelayUrl; +use nostr_sdk::prelude::{RelayUrl, Timestamp}; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use theme::ActiveTheme; @@ -40,19 +40,21 @@ pub struct BrowsePanel { /// The groups the relay has announced so far candidates: Vec, /// The groups discovered from the cache and the relays - discovery: Entity, + discovery: Discovery, + /// Whether the discovery sync is still running + discovering: bool, /// The scroll position of the group list scroll_handle: VirtualListScrollHandle, /// Whether the relay is still answering loading: bool, - _subscriptions: SmallVec<[Subscription; 3]>, + tasks: SmallVec<[Task<()>; 2]>, + _subscriptions: SmallVec<[Subscription; 2]>, } impl BrowsePanel { fn new(dock: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { let input = cx.new(|cx| InputState::new(window, cx).placeholder(INPUT_PLACEHOLDER)); let registry = GroupsRegistry::global(cx); - let discovery = registry.read(cx).discovery(); let mut subscriptions = smallvec![]; @@ -64,10 +66,6 @@ impl BrowsePanel { }), ); - subscriptions.push(cx.observe(&discovery, |_, _, cx| { - cx.notify(); - })); - subscriptions.push( cx.subscribe(®istry, |this, _registry, event, cx| match event { GroupsEvent::Browsed(candidate) => { @@ -76,15 +74,23 @@ impl BrowsePanel { GroupsEvent::BrowseSettled(relay) => { this.settle(relay.clone(), cx); } + GroupsEvent::Synced => { + this.refresh_discovery(cx); + } + GroupsEvent::Members { + key, + created_at, + members, + } => { + this.push_members(key.clone(), *created_at, *members, cx); + } _ => {} }), ); - // Wait for the panel to leave the stack before kicking off the sweep. - cx.defer_in(window, |_this, _window, cx| { - GroupsRegistry::global(cx).update(cx, |registry, cx| { - registry.discover(cx); - }); + cx.defer_in(window, |this, _window, cx| { + this.load_cached(cx); + this.sync_discovery(cx); }); Self { @@ -93,13 +99,77 @@ impl BrowsePanel { input, relay: None, candidates: Vec::new(), - discovery, + discovery: Discovery::default(), + discovering: true, scroll_handle: VirtualListScrollHandle::new(), loading: false, + tasks: smallvec![], _subscriptions: subscriptions, } } + /// Load the groups from the local database. + fn load_cached(&mut self, cx: &mut Context) { + let registry = GroupsRegistry::global(cx); + let discovery = registry.read(cx).cached_discovery(cx); + + self.tasks.push(cx.spawn(async move |this, cx| { + let discovery = discovery.await; + + this.update(cx, |this, cx| { + this.discovery = discovery; + cx.notify(); + }) + .ok(); + })); + } + + /// Sync the discoverable groups with the relays. + fn sync_discovery(&mut self, cx: &mut Context) { + self.discovering = true; + + let registry = GroupsRegistry::global(cx); + let sync = registry.read(cx).discover(cx); + + self.tasks.push(cx.spawn(async move |this, cx| { + sync.await; + + this.update(cx, |this, cx| { + this.discovering = false; + cx.notify(); + }) + .ok(); + })); + } + + /// A browsed group announced its member list. + fn push_members( + &mut self, + key: GroupKey, + created_at: Timestamp, + members: usize, + cx: &mut Context, + ) { + self.discovery.set_members(key, created_at, members); + cx.notify(); + } + + /// The discovery synced new groups: re-query the local database. + fn refresh_discovery(&mut self, cx: &mut Context) { + let registry = GroupsRegistry::global(cx); + let discovery = registry.read(cx).cached_discovery(cx); + + self.tasks.push(cx.spawn(async move |this, cx| { + let discovery = discovery.await; + + this.update(cx, |this, cx| { + this.discovery = discovery; + cx.notify(); + }) + .ok(); + })); + } + /// Browse the relay in the input. fn browse(&mut self, window: &mut Window, cx: &mut Context) { let registry = GroupsRegistry::global(cx); @@ -189,7 +259,6 @@ impl BrowsePanel { let cards: Vec = self .discovery - .read(cx) .discovered() .iter() .filter(|candidate| !joined.contains(&candidate.key)) @@ -242,7 +311,7 @@ impl BrowsePanel { fn render_card(&self, candidate: &GroupCandidate, cx: &mut Context) -> AnyElement { let key = candidate.key.clone(); let seed = key.cache_tag(); - let members = self.discovery.read(cx).members(&key).unwrap_or(0); + let members = self.discovery.members(&key).unwrap_or(0); let name = SharedString::from(candidate.display_name().to_owned()); let relay = SharedString::from(key.relay().as_str().to_owned()); @@ -346,11 +415,11 @@ impl BrowsePanel { h_flex() .w_full() .gap_1p5() - .child(Icon::new(IconName::Group)) + .text_color(cx.theme().text_muted) + .child(Icon::new(IconName::Book)) .child( div() .text_sm() - .text_color(cx.theme().text_muted) .child(SharedString::from(format!("{members} members"))), ), ), @@ -392,8 +461,6 @@ impl Render for BrowsePanel { let (discovered_count, discovering) = if discover { let registry = GroupsRegistry::global(cx); - let discovery = self.discovery.read(cx); - let discovering = discovery.loading(); let joined: Vec = registry .read(cx) @@ -402,13 +469,14 @@ impl Render for BrowsePanel { .map(|group| group.read(cx).key().clone()) .collect(); - let discovered_count = discovery + let discovered_count = self + .discovery .discovered() .iter() .filter(|candidate| !joined.contains(&candidate.key)) .count(); - (discovered_count, discovering) + (discovered_count, self.discovering) } else { (0, false) }; -- 2.54.0 From 65c617aff15cc0a1bc6a988a015f620792376a97 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 8 Oct 2026 04:27:26 +0700 Subject: [PATCH 07/10] scroll the browse panel header with the group cards --- crates/workspace/src/panels/browse.rs | 279 +++++++++++++------------- 1 file changed, 145 insertions(+), 134 deletions(-) diff --git a/crates/workspace/src/panels/browse.rs b/crates/workspace/src/panels/browse.rs index e43030ed..69910689 100644 --- a/crates/workspace/src/panels/browse.rs +++ b/crates/workspace/src/panels/browse.rs @@ -4,8 +4,9 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - IntoElement, ParentElement, Render, ScrollStrategy, SharedString, Styled, Subscription, Task, - WeakEntity, Window, div, px, relative, size, + InteractiveElement, IntoElement, ParentElement, Render, ScrollStrategy, SharedString, + StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, px, relative, + size, }; use gpui_base::{VirtualListScrollHandle, v_virtual_list}; use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry}; @@ -23,6 +24,7 @@ use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_fle const TITLE: &str = "Browse"; const INPUT_PLACEHOLDER: &str = "wss://relay.example.com"; +const HEADER_HEIGHT: f32 = 96.0; const CARD_HEIGHT: f32 = 164.0; const NO_DESCRIPTION: &str = "No description"; @@ -43,7 +45,7 @@ pub struct BrowsePanel { discovery: Discovery, /// Whether the discovery sync is still running discovering: bool, - /// The scroll position of the group list + /// The scroll position of the panel scroll_handle: VirtualListScrollHandle, /// Whether the relay is still answering loading: bool, @@ -222,8 +224,7 @@ impl BrowsePanel { } /// Join a browsed group and open it. - fn join(&self, key: GroupKey, window: &mut Window, cx: &mut App) { - let dock = self.dock.clone(); + fn join(dock: &WeakEntity, key: GroupKey, window: &mut Window, cx: &mut App) { let registry = GroupsRegistry::global(cx); registry.update(cx, |registry, cx| { @@ -235,7 +236,7 @@ impl BrowsePanel { }; ui::dock::add_panel_to( - &dock, + dock, PanelHandle::new(nip29_ui::init(group, window, cx)), DockPlacement::Center, window, @@ -243,67 +244,114 @@ impl BrowsePanel { ); } - fn render_discovered_rows( - &mut self, - range: Range, - cx: &mut Context, - ) -> Vec { - let registry = GroupsRegistry::global(cx); - - let joined: Vec = registry + /// The discovered groups the user has not joined yet. + fn discovered_cards(&self, cx: &App) -> Vec { + let joined: Vec = GroupsRegistry::global(cx) .read(cx) .groups() .iter() .map(|group| group.read(cx).key().clone()) .collect(); - let cards: Vec = self - .discovery + self.discovery .discovered() .iter() .filter(|candidate| !joined.contains(&candidate.key)) - .skip(range.start * 2) - .take((range.end - range.start) * 2) .cloned() - .collect(); - - self.render_card_rows(&cards, cx) + .collect() } - fn render_browse_rows( - &mut self, - range: Range, - cx: &mut Context, - ) -> Vec { - let start = range.start * 2; - let end = (range.end * 2).min(self.candidates.len()); - let cards = self.candidates.get(start..end).unwrap_or(&[]); - - self.render_card_rows(cards, cx) - } - - fn render_card_rows( - &self, - cards: &[GroupCandidate], - cx: &mut Context, - ) -> Vec { - cards - .chunks(2) - .map(|pair| { - let first = pair.first(); - let second = pair.get(1); - + /// The panel header, item 0 of the list so it scrolls with the cards. + fn render_header(&self, cx: &Context) -> AnyElement { + h_flex() + .w_full() + .px_4() + .h_24() + .flex_shrink_0() + .justify_between() + .child( + v_flex() + .min_w_0() + .child( + div() + .text_sm() + .font_semibold() + .line_height(relative(1.2)) + .child("Public Groups"), + ) + .child( + div() + .truncate() + .text_xs() + .text_color(cx.theme().text_muted) + .map(|this| match &self.relay { + Some(relay) => { + this.child(SharedString::from(relay.as_str().to_owned())) + } + None => this.child(SharedString::from( + "Hosted on current connected relays", + )), + }), + ), + ) + .child( h_flex() + .gap_1() .w_full() - .h(px(CARD_HEIGHT)) - .p_2() - .gap_2() - .children(first.map(|card| self.render_card(card, cx))) - .child(match second { - Some(card) => self.render_card(card, cx), - None => div().flex_1().into_any_element(), - }) - .into_any_element() + .max_w_64() + .px_1() + .rounded(cx.theme().radius) + .bg(cx.theme().surface_background) + .child( + Input::new(&self.input) + .appearance(false) + .small() + .text_sm() + .flex_1(), + ) + .child( + Button::new("relay-browse") + .icon(IconName::ArrowRight) + .tooltip("Browse by Relay") + .ghost() + .small() + .on_click(cx.listener(|this, _event, window, cx| { + this.browse(window, cx); + })), + ), + ) + .into_any_element() + } + + fn render_list_rows(&mut self, range: Range, cx: &mut Context) -> Vec { + let discover = self.relay.is_none(); + + let cards: Vec = if discover { + self.discovered_cards(cx) + } else { + self.candidates.clone() + }; + + range + .map(|ix| { + if ix == 0 { + self.render_header(cx) + } else { + let pair = cards.chunks(2).nth(ix - 1).unwrap_or(&[]); + + h_flex() + .w_full() + .h(px(CARD_HEIGHT)) + .px_4() + .pb_4() + .gap_4() + .children(pair.first().map(|card| self.render_card(card, cx))) + .child(match pair.get(1) { + Some(card) => self.render_card(card, cx), + None => div().flex_1().into_any_element(), + }) + .into_any_element() + } }) .collect() } @@ -337,6 +385,7 @@ impl BrowsePanel { .into_any_element() } else { let panel = cx.entity().downgrade(); + let dock = self.dock.clone(); let clicked = key; Button::new(format!("join-{seed}")) @@ -344,9 +393,9 @@ impl BrowsePanel { .small() .ghost() .on_click(move |_event, window, cx| { - let _ = panel.update(cx, |panel, cx| { - panel.join(clicked.clone(), window, cx); - }); + Self::join(&dock, clicked.clone(), window, cx); + + let _ = panel.update(cx, |_, cx| cx.notify()); }) .into_any_element() }; @@ -435,7 +484,8 @@ impl Panel for BrowsePanel { fn title(&self, cx: &App) -> AnyElement { h_flex() - .gap_1p5() + .gap_1() + .text_xs() .child( Icon::new(IconName::Compass) .small() @@ -456,97 +506,58 @@ impl Focusable for BrowsePanel { impl Render for BrowsePanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let count = self.candidates.len(); + let view = cx.entity(); let discover = self.relay.is_none(); - let (discovered_count, discovering) = if discover { - let registry = GroupsRegistry::global(cx); - - let joined: Vec = registry - .read(cx) - .groups() - .iter() - .map(|group| group.read(cx).key().clone()) - .collect(); - - let discovered_count = self - .discovery - .discovered() - .iter() - .filter(|candidate| !joined.contains(&candidate.key)) - .count(); - - (discovered_count, self.discovering) + let cards: Vec = if discover { + self.discovered_cards(cx) } else { - (0, false) + self.candidates.clone() }; - let body = if discover { - if discovered_count > 0 { - let rows = discovered_count.div_ceil(2); - let item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]); + let rows = cards.len().div_ceil(2); - v_virtual_list( - cx.entity(), - "discovered", - item_sizes, - |this, range, _, cx| this.render_discovered_rows(range, cx), - ) - .track_scroll(&self.scroll_handle) - .h_full() - .into_any_element() - } else if discovering { - notice("Discovering groups…", cx) - } else { - notice("No groups found.", cx) - } - } else if count > 0 { - let rows = count.div_ceil(2); - let item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]); + let content: AnyElement = if rows > 0 { + let mut item_sizes = vec![size(px(0.), px(HEADER_HEIGHT))]; + item_sizes.resize(rows + 1, size(px(0.), px(CARD_HEIGHT))); - v_virtual_list(cx.entity(), "groups", item_sizes, |this, range, _, cx| { - this.render_browse_rows(range, cx) - }) + v_virtual_list( + view, + "groups", + Rc::new(item_sizes), + |this, range, _window, cx| this.render_list_rows(range, cx), + ) .track_scroll(&self.scroll_handle) .h_full() .into_any_element() - } else if self.loading { - notice("Loading groups…", cx) } else { - notice("No groups found.", cx) + let text = if discover { + if self.discovering { + "Discovering groups…" + } else { + "No groups found." + } + } else if self.loading { + "Loading groups…" + } else { + "No groups found." + }; + + v_flex() + .id("browse-content") + .size_full() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .child(self.render_header(cx)) + .child(notice(text, cx)) + .into_any_element() }; - v_flex() + div() .size_full() - .child( - v_flex() - .h_32() - .flex_shrink_0() - .items_center() - .justify_center() - .bg(cx.theme().surface_background) - .child( - h_flex() - .w_full() - .max_w_56() - .gap_1() - .child(Input::new(&self.input).cleanable(true).flex_1()) - .child(Button::new("browse-go").label("Go").primary().on_click( - cx.listener(|this, _event, window, cx| { - this.browse(window, cx); - }), - )), - ), - ) - .child( - div() - .flex_1() - .min_h_0() - .relative() - .px_2() - .child(body) - .child(Scrollbar::vertical(&self.scroll_handle)), - ) + .relative() + .child(content) + .child(Scrollbar::vertical(&self.scroll_handle)) } } -- 2.54.0 From fbf01de8739b5d66d5b022474200947c6950c8c9 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 8 Oct 2026 04:54:10 +0700 Subject: [PATCH 08/10] add group preview mode to browse panel --- crates/nip29/src/lib.rs | 53 +++++++++++-- crates/nip29_ui/src/lib.rs | 103 ++++++++++++-------------- crates/workspace/src/panels/browse.rs | 66 +++++++---------- 3 files changed, 119 insertions(+), 103 deletions(-) diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index ad019b6e..10420191 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -65,6 +65,7 @@ impl EventEmitter for GroupsRegistry {} pub struct GroupsRegistry { groups: Vec>, + previews: HashMap>, list: GroupList, browse: HashMap, routes: HashMap, @@ -120,6 +121,7 @@ impl GroupsRegistry { Self { groups: Vec::new(), + previews: HashMap::new(), list: GroupList::default(), browse: HashMap::new(), routes: HashMap::new(), @@ -145,6 +147,7 @@ impl GroupsRegistry { .iter() .find(|group| group.read(cx).key() == key) .cloned() + .or_else(|| self.previews.get(key).cloned()) } pub fn join(&mut self, reference: GroupReference, cx: &mut Context) { @@ -268,6 +271,11 @@ impl GroupsRegistry { .collect() } + /// Whether the group is one of the user's groups, not a preview. + fn joined(&self, key: &GroupKey, cx: &App) -> bool { + self.groups.iter().any(|group| group.read(cx).key() == key) + } + /// Load the discoverable groups from the local database only. pub fn cached_discovery(&self, cx: &App) -> Task { let client = NostrRegistry::global(cx).read(cx).client(); @@ -340,6 +348,7 @@ impl GroupsRegistry { let routes = std::mem::take(&mut self.routes); self.groups.clear(); + self.previews.clear(); self.observers.clear(); self.forks.clear(); self.fork_authors.clear(); @@ -532,6 +541,12 @@ impl GroupsRegistry { } fn ensure(&mut self, key: GroupKey, cx: &mut Context) { + // A previewed group becomes one of the user's groups, keeping its state. + if let Some(group) = self.previews.remove(&key) { + self.groups.push(group); + return; + } + if self.group(&key, cx).is_some() { return; } @@ -539,9 +554,31 @@ impl GroupsRegistry { let group = cx.new(|_| Group::new(key.clone())); self.groups.push(group.clone()); + self.observe_group(key.clone(), &group, cx); + self.subscribe(key.clone(), cx); + self.load_state(key, cx); + } + + /// Track a group opened for preview: connected, but not joined. + pub fn open(&mut self, key: GroupKey, cx: &mut Context) -> Entity { + if let Some(group) = self.group(&key, cx) { + return group; + } + + let group = cx.new(|_| Group::new(key.clone())); + self.previews.insert(key.clone(), group.clone()); + + self.observe_group(key.clone(), &group, cx); + self.subscribe(key.clone(), cx); + self.load_state(key, cx); + + group + } + + fn observe_group(&mut self, key: GroupKey, group: &Entity, cx: &mut Context) { self.observers.insert( - key.clone(), - cx.subscribe(&group, |this, group, event, cx| match event { + key, + cx.subscribe(group, |this, group, event, cx| match event { GroupEvent::Sent(event) => { if event.kind == Kind::GroupDeleteEvent { this.forget_event(group, event.clone(), cx); @@ -551,7 +588,13 @@ impl GroupsRegistry { } } GroupEvent::Updated => { - this.refresh_entry(group, cx); + let key = group.read(cx).key().clone(); + + // Only the user's groups belong in their group list. + if this.joined(&key, cx) { + this.refresh_entry(group, cx); + } + this.sync_forks(false, cx); cx.emit(GroupsEvent::Updated); @@ -560,9 +603,6 @@ impl GroupsRegistry { _ => {} }), ); - - self.subscribe(key.clone(), cx); - self.load_state(key, cx); } fn subscribe(&mut self, key: GroupKey, cx: &mut Context) { @@ -586,6 +626,7 @@ impl GroupsRegistry { self.routes.remove(&page); self.observers.remove(&key); self.groups.retain(|group| group.read(cx).key() != &key); + self.previews.remove(&key); self.list.remove(&key); let nostr = NostrRegistry::global(cx); diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 4da55506..d56f1fec 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -9,7 +9,7 @@ use gpui::{ PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px, }; -use nip29::{Group, GroupEvent, GroupsRegistry, Membership, Pin, Row}; +use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row}; use nostr_sdk::prelude::*; use person::PersonRegistry; use serde::Deserialize; @@ -64,33 +64,8 @@ enum Notice { impl Notice { fn of(group: &Group) -> Option { - if let Some(reason) = group.refused() { - return Some(Self::Relay(reason.to_owned())); - } - - let closed = group.metadata().is_some_and(|metadata| metadata.closed); - - match group.membership() { - Membership::Member => {} - Membership::Pending { .. } => return Some(Self::Pending), - Membership::Refused { reason } => { - return Some(Self::Join { - refused: reason.clone(), - closed, - }); - } - Membership::Unknown | Membership::Removed => { - return Some(Self::Join { - refused: None, - closed, - }); - } - } - - group - .metadata() - .is_some_and(|metadata| !metadata.supports(Kind::ChatMessage)) - .then_some(Self::Unsupported) + let reason = group.refused()?; + Some(Self::Relay(reason.to_owned())) } } @@ -654,12 +629,8 @@ impl GroupPanel { Button::new("group-moved") .label("Join") .small() - .on_click(cx.listener(move |_this, _event, _window, cx| { - let registry = GroupsRegistry::global(cx); - - registry.update(cx, |registry, cx| { - registry.join_key(moved.clone(), None, cx); - }); + .on_click(cx.listener(move |this, _event, _window, cx| { + this.request_join(moved.clone(), cx); })), ) .into_any_element() @@ -758,6 +729,13 @@ impl GroupPanel { .into_any_element() } + /// Ask the relay to admit the user into the group. + fn request_join(&self, key: GroupKey, cx: &mut App) { + GroupsRegistry::global(cx).update(cx, |registry, cx| { + registry.join_key(key, None, cx); + }); + } + fn render_notice(&self, notice: &Notice, cx: &mut Context) -> AnyElement { let row = h_flex() .w_full() @@ -767,7 +745,7 @@ impl GroupPanel { .px_3() .py_2() .text_sm() - .text_color(cx.theme().text_placeholder); + .text_color(cx.theme().text_muted); match notice { Notice::Relay(reason) => row @@ -781,7 +759,7 @@ impl GroupPanel { if *closed { return row - .child("This group is closed; an invite link is required.") + .child("Group is closed. An invite link is required.") .into_any_element(); } @@ -793,17 +771,17 @@ impl GroupPanel { return row.into_any_element(); }; - row.child( - Button::new("join-group") - .label("Request to join") - .small() - .on_click(cx.listener(move |_this, _event, _window, cx| { - GroupsRegistry::global(cx).update(cx, |registry, cx| { - registry.join_key(key.clone(), None, cx); - }); - })), - ) - .into_any_element() + row.child("You're in preview mode. Join this group to talk.") + .child( + Button::new("join") + .label("Request to Join") + .small() + .ghost_alt() + .on_click(cx.listener(move |this, _event, _window, cx| { + this.request_join(key.clone(), cx); + })), + ) + .into_any_element() } Notice::Pending => row .child("Waiting for the relay to approve.") @@ -871,18 +849,31 @@ impl GroupPanel { ) } - fn render_composer(&self, cx: &mut Context) -> impl IntoElement { - let can_send = self.group.upgrade().is_some_and(|group| { + fn render_composer(&self, cx: &mut Context) -> AnyElement { + let notice = self.group.upgrade().and_then(|group| { let group = group.read(cx); - let metadata = group.metadata(); - let supports_text = - metadata.is_none_or(|metadata| metadata.supports(Kind::ChatMessage)); - let writable = metadata.is_none_or(|metadata| !metadata.restricted) - || matches!(group.membership(), Membership::Member); - supports_text && writable + if !group + .metadata() + .is_none_or(|metadata| metadata.supports(Kind::ChatMessage)) + { + return Some(Notice::Unsupported); + } + + match group.membership() { + Membership::Member => None, + Membership::Pending { .. } => Some(Notice::Pending), + _ => Some(Notice::Join { + refused: None, + closed: group.metadata().is_some_and(|metadata| metadata.closed), + }), + } }); + if let Some(notice) = notice { + return self.render_notice(¬ice, cx); + } + v_flex() .flex_shrink_0() .w_full() @@ -912,12 +903,12 @@ impl GroupPanel { .tooltip("Send") .ghost() .large() - .disabled(!can_send) .on_click(cx.listener(|this, _event, window, cx| { this.send(window, cx); })), ), ) + .into_any_element() } fn render_attachments(&self, cx: &mut Context) -> Vec { diff --git a/crates/workspace/src/panels/browse.rs b/crates/workspace/src/panels/browse.rs index 69910689..b39319a8 100644 --- a/crates/workspace/src/panels/browse.rs +++ b/crates/workspace/src/panels/browse.rs @@ -9,7 +9,7 @@ use gpui::{ size, }; use gpui_base::{VirtualListScrollHandle, v_virtual_list}; -use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry}; +use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry, Membership}; use nostr_sdk::prelude::{RelayUrl, Timestamp}; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; @@ -20,7 +20,7 @@ use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; use ui::input::{Input, InputEvent, InputState}; use ui::notification::Notification; use ui::scroll::Scrollbar; -use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; +use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; const TITLE: &str = "Browse"; const INPUT_PLACEHOLDER: &str = "wss://relay.example.com"; @@ -223,17 +223,10 @@ impl BrowsePanel { cx.notify(); } - /// Join a browsed group and open it. - fn join(dock: &WeakEntity, key: GroupKey, window: &mut Window, cx: &mut App) { + /// Open the group's panel to preview (or revisit) it. + fn open_panel(dock: &WeakEntity, key: GroupKey, window: &mut Window, cx: &mut App) { let registry = GroupsRegistry::global(cx); - - registry.update(cx, |registry, cx| { - registry.join_key(key.clone(), None, cx); - }); - - let Some(group) = registry.read(cx).group(&key, cx) else { - return; - }; + let group = registry.update(cx, |registry, cx| registry.open(key, cx)); ui::dock::add_panel_to( dock, @@ -244,19 +237,20 @@ impl BrowsePanel { ); } - /// The discovered groups the user has not joined yet. + /// The discovered groups the user is not a member of. fn discovered_cards(&self, cx: &App) -> Vec { - let joined: Vec = GroupsRegistry::global(cx) + let member_of: Vec = GroupsRegistry::global(cx) .read(cx) .groups() .iter() + .filter(|group| matches!(group.read(cx).membership(), Membership::Member)) .map(|group| group.read(cx).key().clone()) .collect(); self.discovery .discovered() .iter() - .filter(|candidate| !joined.contains(&candidate.key)) + .filter(|candidate| !member_of.contains(&candidate.key)) .cloned() .collect() } @@ -357,6 +351,10 @@ impl BrowsePanel { } fn render_card(&self, candidate: &GroupCandidate, cx: &mut Context) -> AnyElement { + let registry = GroupsRegistry::global(cx); + let hide_avatar = AppSettings::get_hide_avatar(cx); + let dock = self.dock.clone(); + let key = candidate.key.clone(); let seed = key.cache_tag(); let members = self.discovery.members(&key).unwrap_or(0); @@ -372,33 +370,19 @@ impl BrowsePanel { .display_about() .map(|about| SharedString::from(about.to_owned())); - let registry = GroupsRegistry::global(cx); - let hide_avatar = AppSettings::get_hide_avatar(cx); - let joined = registry.read(cx).group(&key, cx).is_some(); + let member = registry + .read(cx) + .group(&key, cx) + .is_some_and(|group| matches!(group.read(cx).membership(), Membership::Member)); - let action = if joined { - Button::new(format!("joined-{seed}")) - .label("Joined") - .small() - .ghost() - .disabled(true) - .into_any_element() - } else { - let panel = cx.entity().downgrade(); - let dock = self.dock.clone(); - let clicked = key; - - Button::new(format!("join-{seed}")) - .label("Join") - .small() - .ghost() - .on_click(move |_event, window, cx| { - Self::join(&dock, clicked.clone(), window, cx); - - let _ = panel.update(cx, |_, cx| cx.notify()); - }) - .into_any_element() - }; + let action = Button::new(format!("open-{seed}")) + .label(if member { "Open" } else { "Preview" }) + .small() + .ghost() + .on_click(move |_event, window, cx| { + Self::open_panel(&dock, key.clone(), window, cx); + }) + .into_any_element(); v_flex() .flex_1() -- 2.54.0 From 26c69eb41feab5972c87cb5364072e4d3ecf1879 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 8 Oct 2026 05:00:55 +0700 Subject: [PATCH 09/10] Make browse cards fully clickable --- crates/workspace/src/panels/browse.rs | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/crates/workspace/src/panels/browse.rs b/crates/workspace/src/panels/browse.rs index b39319a8..29aee0e6 100644 --- a/crates/workspace/src/panels/browse.rs +++ b/crates/workspace/src/panels/browse.rs @@ -351,7 +351,6 @@ impl BrowsePanel { } fn render_card(&self, candidate: &GroupCandidate, cx: &mut Context) -> AnyElement { - let registry = GroupsRegistry::global(cx); let hide_avatar = AppSettings::get_hide_avatar(cx); let dock = self.dock.clone(); @@ -370,21 +369,8 @@ impl BrowsePanel { .display_about() .map(|about| SharedString::from(about.to_owned())); - let member = registry - .read(cx) - .group(&key, cx) - .is_some_and(|group| matches!(group.read(cx).membership(), Membership::Member)); - - let action = Button::new(format!("open-{seed}")) - .label(if member { "Open" } else { "Preview" }) - .small() - .ghost() - .on_click(move |_event, window, cx| { - Self::open_panel(&dock, key.clone(), window, cx); - }) - .into_any_element(); - v_flex() + .id(SharedString::from(format!("card-{seed}"))) .flex_1() .min_w_0() .h_full() @@ -392,6 +378,11 @@ impl BrowsePanel { .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius) + .cursor_pointer() + .hover(|this| { + this.border_color(cx.theme().border_focused) + .bg(cx.theme().surface_background) + }) .child( v_flex() .flex_1() @@ -425,8 +416,7 @@ impl BrowsePanel { .text_color(cx.theme().text_placeholder) .child(relay), ), - ) - .child(action), + ), ) .child( div() @@ -457,6 +447,9 @@ impl BrowsePanel { ), ), ) + .on_click(move |_event, window, cx| { + Self::open_panel(&dock, key.clone(), window, cx); + }) .into_any_element() } } -- 2.54.0 From c40388bdc530c29c455f0cd6a7267675ceb11b54 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 8 Oct 2026 07:17:20 +0700 Subject: [PATCH 10/10] update --- crates/workspace/src/panels/browse.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/workspace/src/panels/browse.rs b/crates/workspace/src/panels/browse.rs index 29aee0e6..fc8fe888 100644 --- a/crates/workspace/src/panels/browse.rs +++ b/crates/workspace/src/panels/browse.rs @@ -375,13 +375,14 @@ impl BrowsePanel { .min_w_0() .h_full() .overflow_hidden() - .border_1() - .border_color(cx.theme().border) .rounded(cx.theme().radius) .cursor_pointer() + .border_1() + .border_color(cx.theme().border_transparent) + .bg(cx.theme().surface_background) .hover(|this| { - this.border_color(cx.theme().border_focused) - .bg(cx.theme().surface_background) + this.bg(cx.theme().elevated_surface_background) + .border_color(cx.theme().border_focused) }) .child( v_flex() -- 2.54.0