From c058270eff7d93a3b4871905c438ac7ae8d4f42f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 7 Oct 2026 08:11:57 +0700 Subject: [PATCH] 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)) } }