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)), + ) } }