feat: add group discovery and group preview #59

Merged
reya merged 10 commits from browse-group into master 2026-10-08 00:18:27 +00:00
6 changed files with 340 additions and 102 deletions
Showing only changes of commit d0f02dfb4b - Show all commits
Generated
+1
View File
@@ -9409,6 +9409,7 @@ dependencies = [
"chat", "chat",
"chat_ui", "chat_ui",
"common", "common",
"gpui-base",
"gpui-pre", "gpui-pre",
"instant", "instant",
"log", "log",
+16 -1
View File
@@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet};
use gpui::Context; use gpui::Context;
use nostr_sdk::prelude::*; 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. /// The groups discovered from the cache and the relays.
#[derive(Default)] #[derive(Default)]
@@ -12,6 +12,8 @@ pub struct Discovery {
refs: HashMap<RelayUrl, HashSet<GroupId>>, refs: HashMap<RelayUrl, HashSet<GroupId>>,
/// The groups whose metadata has resolved so far. /// The groups whose metadata has resolved so far.
discovered: Vec<GroupCandidate>, discovered: Vec<GroupCandidate>,
/// The member counts of the known groups.
members: HashMap<GroupKey, usize>,
/// The in-flight preview subscriptions, keyed by subscription id. /// The in-flight preview subscriptions, keyed by subscription id.
previews: HashMap<SubscriptionId, RelayUrl>, previews: HashMap<SubscriptionId, RelayUrl>,
/// Whether the contacts' group lists have been answered. /// Whether the contacts' group lists have been answered.
@@ -147,9 +149,22 @@ impl Discovery {
cx.notify(); cx.notify();
} }
/// Apply a member count for a group.
pub(crate) fn set_members(&mut self, key: GroupKey, members: usize, cx: &mut Context<Self>) {
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<usize> {
self.members.get(key).copied()
}
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) { pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
self.refs.clear(); self.refs.clear();
self.discovered.clear(); self.discovered.clear();
self.members.clear();
self.previews.clear(); self.previews.clear();
self.lists_settled = false; self.lists_settled = false;
self.sweep_settled = false; self.sweep_settled = false;
+119 -6
View File
@@ -114,6 +114,18 @@ fn is_own_subscription(id: &SubscriptionId) -> bool {
|| id.starts_with(PAGE_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)> {
if event.kind != Kind::GroupMembers {
return None;
}
let id = GroupId::new(event.tags.identifier()?).ok()?;
let members = event.tags.public_keys().collect::<HashSet<_>>().len();
Some((id, members))
}
impl EventEmitter<GroupsEvent> for GroupsRegistry {} impl EventEmitter<GroupsEvent> for GroupsRegistry {}
pub struct GroupsRegistry { pub struct GroupsRegistry {
@@ -282,13 +294,15 @@ impl GroupsRegistry {
self.tasks.push(cx.background_spawn(async move { self.tasks.push(cx.background_spawn(async move {
client.add_relay(relay.clone()).and_connect().await.ok(); 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() let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE) .exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(BROWSE_TIMEOUT)); .timeout(Some(BROWSE_TIMEOUT));
if let Err(error) = client if let Err(error) = client
.subscribe(ReqTarget::single(relay.clone(), [filter])) .subscribe(ReqTarget::single(relay.clone(), [metadata, members]))
.with_id(id.clone()) .with_id(id.clone())
.close_on(opts) .close_on(opts)
.await .await
@@ -386,6 +400,7 @@ impl GroupsRegistry {
return; return;
} }
// Show the cached metadata before the relays answer.
let previews = match this.update(cx, |this, cx| this.load_cached_previews(cx)) { let previews = match this.update(cx, |this, cx| this.load_cached_previews(cx)) {
Ok(task) => match task.await { Ok(task) => match task.await {
Ok(previews) => previews, 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| { if let Err(error) = this.update(cx, |this, cx| {
this.absorb_cached_previews(previews, 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_contacts(authors, cx);
this.subscribe_discovery(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<Self>) -> Task<Result<Vec<Event>, 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<Event>, cx: &mut Context<Self>) {
let newest: HashMap<GroupId, (Timestamp, usize)> =
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. /// Subscribe to the contacts' public group lists.
fn subscribe_contacts(&mut self, authors: HashSet<PublicKey>, cx: &mut Context<Self>) { fn subscribe_contacts(&mut self, authors: HashSet<PublicKey>, cx: &mut Context<Self>) {
if authors.is_empty() { if authors.is_empty() {
@@ -531,7 +618,8 @@ impl GroupsRegistry {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); 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() let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE) .exit_policy(ReqExitPolicy::ExitOnEOSE)
@@ -541,7 +629,7 @@ impl GroupsRegistry {
self.tasks.push(cx.background_spawn(async move { self.tasks.push(cx.background_spawn(async move {
let refused = match client let refused = match client
.subscribe(ReqTarget::auto([filter])) .subscribe(ReqTarget::auto([metadata, members]))
.with_id(discovery_subscription_id()) .with_id(discovery_subscription_id())
.close_on(opts) .close_on(opts)
.await .await
@@ -564,6 +652,13 @@ impl GroupsRegistry {
/// Absorb a discovered group metadata event from the relay serving it. /// Absorb a discovered group metadata event from the relay serving it.
fn absorb_discovered(&mut self, relay: RelayUrl, event: &Event, cx: &mut Context<Self>) { fn absorb_discovered(&mut self, relay: RelayUrl, event: &Event, cx: &mut Context<Self>) {
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) { let metadata = match GroupMetadata::parse(event) {
Ok(metadata) => metadata, Ok(metadata) => metadata,
Err(error) => { Err(error) => {
@@ -609,10 +704,14 @@ impl GroupsRegistry {
}); });
let client = client.clone(); let client = client.clone();
let filter = Filter::new() let metadata = Filter::new()
.kind(Kind::GroupMetadata) .kind(Kind::GroupMetadata)
.identifiers(ids.iter().map(GroupId::as_str)); .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() let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE) .exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(BROWSE_TIMEOUT)); .timeout(Some(BROWSE_TIMEOUT));
@@ -623,7 +722,7 @@ impl GroupsRegistry {
client.add_relay(relay.clone()).and_connect().await.ok(); client.add_relay(relay.clone()).and_connect().await.ok();
let refused = match client let refused = match client
.subscribe(ReqTarget::single(relay.clone(), [filter])) .subscribe(ReqTarget::single(relay.clone(), [metadata, members]))
.with_id(id.clone()) .with_id(id.clone())
.close_on(opts) .close_on(opts)
.await .await
@@ -677,6 +776,13 @@ impl GroupsRegistry {
return; 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) { let metadata = match GroupMetadata::parse(event) {
Ok(metadata) => metadata, Ok(metadata) => metadata,
Err(error) => { Err(error) => {
@@ -1316,6 +1422,13 @@ impl GroupsRegistry {
return; 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 { let Ok(metadata) = GroupMetadata::parse(event) else {
log::warn!("nip29: ignoring malformed group metadata from {relay}"); log::warn!("nip29: ignoring malformed group metadata from {relay}");
return; return;
+4
View File
@@ -425,6 +425,10 @@ impl GroupCandidate {
pub fn display_image(&self) -> Option<&str> { pub fn display_image(&self) -> Option<&str> {
self.metadata.picture.as_deref() self.metadata.picture.as_deref()
} }
pub fn display_about(&self) -> Option<&str> {
self.metadata.about.as_deref()
}
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
+1
View File
@@ -19,6 +19,7 @@ person = { path = "../person" }
auto_update = { path = "../auto_update" } auto_update = { path = "../auto_update" }
gpui.workspace = true gpui.workspace = true
gpui-base.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true
instant.workspace = true instant.workspace = true
nostr-connect.workspace = true nostr-connect.workspace = true
+163 -59
View File
@@ -1,11 +1,13 @@
use std::ops::Range; use std::ops::Range;
use std::rc::Rc;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, WeakEntity, Window, IntoElement, ParentElement, Render, ScrollStrategy, SharedString, Styled, Subscription,
div, uniform_list, WeakEntity, Window, div, px, size,
}; };
use gpui_base::{VirtualListScrollHandle, v_virtual_list};
use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry}; use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::RelayUrl; use nostr_sdk::prelude::RelayUrl;
use settings::AppSettings; use settings::AppSettings;
@@ -17,11 +19,16 @@ use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use ui::indicator::Indicator; use ui::indicator::Indicator;
use ui::input::{Input, InputEvent, InputState}; use ui::input::{Input, InputEvent, InputState};
use ui::notification::Notification; use ui::notification::Notification;
use ui::scroll::Scrollbar;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
const TITLE: &str = "Browse"; const TITLE: &str = "Browse";
const INPUT_PLACEHOLDER: &str = "wss://relay.example.com"; 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<DockArea>, window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> { pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
cx.new(|cx| BrowsePanel::new(dock, window, cx)) cx.new(|cx| BrowsePanel::new(dock, window, cx))
} }
@@ -37,6 +44,8 @@ pub struct BrowsePanel {
candidates: Vec<GroupCandidate>, candidates: Vec<GroupCandidate>,
/// The groups discovered from the cache and the relays /// The groups discovered from the cache and the relays
discovery: Entity<Discovery>, discovery: Entity<Discovery>,
/// The scroll position of the group list
scroll_handle: VirtualListScrollHandle,
/// Whether the relay is still answering /// Whether the relay is still answering
loading: bool, loading: bool,
_subscriptions: SmallVec<[Subscription; 3]>, _subscriptions: SmallVec<[Subscription; 3]>,
@@ -88,6 +97,7 @@ impl BrowsePanel {
relay: None, relay: None,
candidates: Vec::new(), candidates: Vec::new(),
discovery, discovery,
scroll_handle: VirtualListScrollHandle::new(),
loading: false, loading: false,
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
@@ -113,6 +123,7 @@ impl BrowsePanel {
self.relay = Some(relay.clone()); self.relay = Some(relay.clone());
self.candidates.clear(); self.candidates.clear();
self.loading = true; self.loading = true;
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
registry.update(cx, |registry, cx| { registry.update(cx, |registry, cx| {
registry.browse(relay, cx); registry.browse(relay, cx);
@@ -165,16 +176,12 @@ impl BrowsePanel {
); );
} }
fn render_rows( fn render_discovered_rows(
&self, &mut self,
candidates: &[GroupCandidate],
relay_detail: bool,
range: Range<usize>, range: Range<usize>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Vec<AnyElement> { ) -> Vec<AnyElement> {
let registry = GroupsRegistry::global(cx); let registry = GroupsRegistry::global(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let panel = cx.entity().downgrade();
let joined: Vec<GroupKey> = registry let joined: Vec<GroupKey> = registry
.read(cx) .read(cx)
@@ -183,55 +190,119 @@ impl BrowsePanel {
.map(|group| group.read(cx).key().clone()) .map(|group| group.read(cx).key().clone())
.collect(); .collect();
candidates let cards: Vec<GroupCandidate> = self
.get(range) .discovery
.into_iter() .read(cx)
.flatten() .discovered()
.map(|candidate| { .iter()
let key = candidate.key.clone(); .filter(|candidate| !joined.contains(&candidate.key))
let name = SharedString::from(candidate.display_name().to_owned()); .skip(range.start * 2)
.take((range.end - range.start) * 2)
.cloned()
.collect();
self.render_card_rows(&cards, range, cx)
}
fn render_browse_rows(
&mut self,
range: Range<usize>,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
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, range, cx)
}
/// A row holds two group cards side by side.
fn render_card_rows(
&self,
cards: &[GroupCandidate],
range: Range<usize>,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
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()
.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<Self>) -> AnyElement {
let key = candidate.key.clone();
let seed = key.cache_tag(); let seed = key.cache_tag();
let is_joined = joined.contains(&key); 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 let picture = candidate
.display_image() .display_image()
.map(|picture| SharedString::from(picture.to_owned())); .map(|picture| SharedString::from(picture.to_owned()));
let detail = if relay_detail { let about = candidate
key.relay().domain().unwrap_or_else(|| key.relay().as_str()) .display_about()
} else { .map(|about| SharedString::from(about.to_owned()));
key.id().as_str()
};
let action = if is_joined { 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}")) Button::new(format!("browse-joined-{seed}"))
.label("Joined") .label("Joined")
.small() .small()
.ghost()
.disabled(true) .disabled(true)
.into_any_element() .into_any_element()
} else { } else {
let panel = panel.clone(); let panel = cx.entity().downgrade();
let clicked = key.clone(); let clicked = key;
Button::new(format!("browse-join-{seed}")) Button::new(format!("browse-join-{seed}"))
.label("Join") .label("Join")
.small() .small()
.ghost()
.on_click(move |_event, window, cx| { .on_click(move |_event, window, cx| {
if let Err(error) = panel.update(cx, |panel, cx| { let _ = panel.update(cx, |panel, cx| {
panel.join(clicked.clone(), window, cx); panel.join(clicked.clone(), window, cx);
}) { });
log::error!("Failed to join a browsed group: {error}");
}
}) })
.into_any_element() .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() h_flex()
.w_full() .w_full()
.gap_2() .items_center()
.px_1p5() .gap_1p5()
.py_1()
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| {
this.child( this.child(
Avatar::from_source(picture) Avatar::from_source(picture)
@@ -250,13 +321,38 @@ impl BrowsePanel {
.text_xs() .text_xs()
.text_color(cx.theme().text_placeholder) .text_color(cx.theme().text_placeholder)
.truncate() .truncate()
.child(SharedString::from(detail.to_owned())), .child(relay),
), ),
) )
.child(action) .child(action),
.into_any_element() )
.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),
)
}) })
.collect() .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()
} }
} }
@@ -301,8 +397,11 @@ impl Render for BrowsePanel {
let count = self.candidates.len(); let count = self.candidates.len();
let discover = self.relay.is_none(); 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 registry = GroupsRegistry::global(cx);
let discovery = self.discovery.read(cx);
let discovering = discovery.loading();
let joined: Vec<GroupKey> = registry let joined: Vec<GroupKey> = registry
.read(cx) .read(cx)
.groups() .groups()
@@ -310,31 +409,29 @@ impl Render for BrowsePanel {
.map(|group| group.read(cx).key().clone()) .map(|group| group.read(cx).key().clone())
.collect(); .collect();
let discovery = self.discovery.read(cx); let discovered_count = discovery
let discovering = discovery.loading();
let discovered: Vec<GroupCandidate> = discovery
.discovered() .discovered()
.iter() .iter()
.filter(|candidate| !joined.contains(&candidate.key)) .filter(|candidate| !joined.contains(&candidate.key))
.cloned() .count();
.collect();
let count = discovered.len(); (discovered_count, discovering)
(discovered, count, discovering)
} else { } else {
(Vec::new(), 0, false) (0, false)
}; };
let body = if discover { let body = if discover {
if discovered_count > 0 { if discovered_count > 0 {
uniform_list( let rows = discovered_count.div_ceil(2);
"browse-discovered", let item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]);
discovered_count,
cx.processor(move |this, range, _window, cx| { v_virtual_list(
this.render_rows(&discovered, true, range, cx) cx.entity(),
}), "discovered",
item_sizes,
|this, range, _, cx| this.render_discovered_rows(range, cx),
) )
.track_scroll(&self.scroll_handle)
.h_full() .h_full()
.into_any_element() .into_any_element()
} else if discovering { } else if discovering {
@@ -343,13 +440,13 @@ impl Render for BrowsePanel {
notice("No groups found.", cx) notice("No groups found.", cx)
} }
} else if count > 0 { } else if count > 0 {
uniform_list( let rows = count.div_ceil(2);
"browse-groups", let item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]);
count,
cx.processor(|this, range, _window, cx| { v_virtual_list(cx.entity(), "groups", item_sizes, |this, range, _, cx| {
this.render_rows(&this.candidates, false, range, cx) this.render_browse_rows(range, cx)
}), })
) .track_scroll(&self.scroll_handle)
.h_full() .h_full()
.into_any_element() .into_any_element()
} else if self.loading { } else if self.loading {
@@ -388,6 +485,13 @@ impl Render for BrowsePanel {
.child("Discovered groups"), .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)),
)
} }
} }