diff --git a/Cargo.lock b/Cargo.lock index e525bb86..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", ] @@ -9409,6 +9411,7 @@ dependencies = [ "chat", "chat_ui", "common", + "gpui-base", "gpui-pre", "instant", "log", 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 new file mode 100644 index 00000000..18d834b1 --- /dev/null +++ b/crates/nip29/src/discovery.rs @@ -0,0 +1,287 @@ +use std::collections::{HashMap, HashSet}; + +use futures::future::join_all; +use nostr_sdk::prelude::*; + +use crate::protocol::{GroupCandidate, GroupId, GroupKey, GroupList, GroupMetadata}; +use crate::utils::parse_members; + +#[derive(Default, Clone, Debug)] +pub struct Discovery { + refs: HashMap>, + discovered: Vec, + members: HashMap, +} + +impl Discovery { + /// The discovered groups, sorted by name. + pub fn discovered(&self) -> &[GroupCandidate] { + &self.discovered + } + + /// 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. + pub(crate) fn refs(&self) -> &HashMap> { + &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) { + match self.discovered.iter_mut().find(|d| d.key == candidate.key) { + Some(existing) if existing.created_at <= candidate.created_at => { + *existing = candidate; + } + Some(_) => return, + None => self.discovered.push(candidate), + } + + self.discovered.sort_by(|a, b| { + a.display_name() + .to_lowercase() + .cmp(&b.display_name().to_lowercase()) + }); + } + + /// Apply a member count, keeping the newest list. + pub fn set_members(&mut self, key: GroupKey, created_at: Timestamp, members: usize) { + if self + .members + .get(&key) + .is_some_and(|(current, _)| *current > created_at) + { + return; + } + + 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(), + }); + } + } + } + + /// 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, + ); + } + } + } + + /// 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 72de7e53..10420191 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; @@ -10,19 +8,18 @@ use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; 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); @@ -41,63 +38,42 @@ 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)] enum Signal { - Event(SubscriptionId, Event), + Event(SubscriptionId, Box), 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) -} - impl EventEmitter for GroupsRegistry {} pub struct GroupsRegistry { groups: Vec>, + previews: HashMap>, list: GroupList, browse: HashMap, routes: HashMap, observers: HashMap, forks: HashMap, fork_authors: HashSet, + /// The relays with a discovery reconciliation in flight. + syncing: HashSet, signal_tx: flume::Sender, signal_rx: flume::Receiver, notification_listener: Option>, @@ -132,26 +108,27 @@ 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 { groups: Vec::new(), + previews: HashMap::new(), list: GroupList::default(), browse: HashMap::new(), routes: HashMap::new(), observers: HashMap::new(), forks: HashMap::new(), fork_authors: HashSet::new(), + syncing: HashSet::new(), signal_tx: tx, signal_rx: rx, notification_listener: None, @@ -170,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) { @@ -207,7 +185,7 @@ impl GroupsRegistry { }); } }) { - log::warn!("nip29: marking the refused join failed: {error}"); + log::warn!("marking the refused join failed: {error}"); } } })); @@ -253,22 +231,24 @@ 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 { - 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}"); } })); } @@ -280,6 +260,49 @@ impl GroupsRegistry { cx.emit(GroupsEvent::BrowseSettled(relay)); } + /// 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() + } + + /// 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(); + let joined = self.joined_keys(cx); + + cx.background_spawn(Discovery::cached(client, joined)) + } + + /// 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(()); + } + + cx.background_spawn(async move { + let Ok(me) = signer.get_public_key_async().await else { + return; + }; + Discovery::default().sync(client, me, joined).await; + }) + } + pub fn load_more(&mut self, key: &GroupKey, cx: &mut Context) { let Some(group) = self.group(key, cx) else { return; @@ -307,7 +330,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| { @@ -325,9 +348,11 @@ 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(); + self.syncing.clear(); self.list = GroupList::default(); let nostr = NostrRegistry::global(cx); @@ -343,7 +368,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}"); } } })); @@ -375,11 +400,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}"), })); } @@ -388,7 +416,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); @@ -399,14 +427,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); } @@ -430,7 +453,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; } @@ -471,14 +493,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; }; @@ -519,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; } @@ -526,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); @@ -538,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); @@ -547,9 +603,6 @@ impl GroupsRegistry { _ => {} }), ); - - self.subscribe(key.clone(), cx); - self.load_state(key, cx); } fn subscribe(&mut self, key: GroupKey, cx: &mut Context) { @@ -573,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); @@ -581,7 +635,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}"); } } @@ -590,7 +644,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}"); } })); @@ -651,18 +705,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}"), } })); } @@ -687,7 +740,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; } @@ -698,7 +751,7 @@ impl GroupsRegistry { }); } Err(error) => { - log::warn!("nip29: reloading {} failed: {error}", logged.id()); + log::warn!("reloading {} failed: {error}", logged.id()); } } })); @@ -725,7 +778,7 @@ impl GroupsRegistry { }); } Err(error) => { - log::warn!("nip29: loading {} state failed: {error}", logged.id()); + log::warn!("loading {} state failed: {error}", logged.id()); } })); } @@ -745,7 +798,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; } @@ -791,7 +844,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| { @@ -807,7 +860,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; } @@ -842,8 +895,17 @@ impl GroupsRegistry { return; }; + if let Some((group, created_at, members)) = parse_members(event) { + cx.emit(GroupsEvent::Members { + key: GroupKey::new(relay, group), + created_at, + members, + }); + return; + } + 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; }; @@ -853,6 +915,7 @@ impl GroupsRegistry { cx.emit(GroupsEvent::Browsed(Box::new(GroupCandidate { key: GroupKey::new(relay, group), + created_at: event.created_at, metadata, }))); } @@ -887,12 +950,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; } @@ -903,7 +966,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() ); @@ -943,44 +1006,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, 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(); + } + _ => {} } } })); @@ -989,7 +1054,7 @@ impl GroupsRegistry { 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); + this.handle_event(id, *event, cx); } Signal::Eose(id) => { this.handle_eose(id, cx); @@ -1000,6 +1065,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/protocol.rs b/crates/nip29/src/protocol.rs index 4bbe2fc1..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, } @@ -425,6 +438,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/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/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/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/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 ce39a236..fc8fe888 100644 --- a/crates/workspace/src/panels/browse.rs +++ b/crates/workspace/src/panels/browse.rs @@ -1,26 +1,32 @@ 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, + InteractiveElement, IntoElement, ParentElement, Render, ScrollStrategy, SharedString, + StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, px, relative, + size, }; -use nip29::{GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry}; -use nostr_sdk::prelude::RelayUrl; +use gpui_base::{VirtualListScrollHandle, v_virtual_list}; +use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry, Membership}; +use nostr_sdk::prelude::{RelayUrl, Timestamp}; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; 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::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; +use ui::scroll::Scrollbar; +use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; 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"; pub fn init(dock: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| BrowsePanel::new(dock, window, cx)) @@ -35,8 +41,15 @@ pub struct BrowsePanel { relay: Option, /// The groups the relay has announced so far candidates: Vec, + /// The groups discovered from the cache and the relays + discovery: Discovery, + /// Whether the discovery sync is still running + discovering: bool, + /// The scroll position of the panel + scroll_handle: VirtualListScrollHandle, /// Whether the relay is still answering loading: bool, + tasks: SmallVec<[Task<()>; 2]>, _subscriptions: SmallVec<[Subscription; 2]>, } @@ -63,21 +76,102 @@ 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); + } _ => {} }), ); + cx.defer_in(window, |this, _window, cx| { + this.load_cached(cx); + this.sync_discovery(cx); + }); + Self { focus_handle: cx.focus_handle(), dock, input, relay: None, candidates: Vec::new(), + 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); @@ -98,6 +192,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); @@ -128,21 +223,13 @@ impl BrowsePanel { cx.notify(); } - /// Join a browsed group and open it. - fn join(&self, key: GroupKey, window: &mut Window, cx: &mut App) { - let dock = self.dock.clone(); + /// 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, + dock, PanelHandle::new(nip29_ui::init(group, window, cx)), DockPlacement::Center, window, @@ -150,95 +237,222 @@ impl BrowsePanel { ); } - fn render_rows(&self, range: Range, cx: &mut Context) -> Vec { - let hide_avatar = AppSettings::get_hide_avatar(cx); - let panel = cx.entity().downgrade(); - - let registry = GroupsRegistry::global(cx); - let joined: Vec = registry + /// The discovered groups the user is not a member of. + fn discovered_cards(&self, cx: &App) -> Vec { + 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.candidates - .get(range) - .into_iter() - .flatten() - .map(|candidate| { - let key = candidate.key.clone(); - let name = SharedString::from(candidate.display_name().to_owned()); - let picture = candidate - .display_image() - .map(|picture| SharedString::from(picture.to_owned())); - let seed = key.cache_tag(); - let is_joined = joined.contains(&key); + self.discovery + .discovered() + .iter() + .filter(|candidate| !member_of.contains(&candidate.key)) + .cloned() + .collect() + } - let action = if is_joined { - Button::new(format!("browse-joined-{seed}")) - .label("Joined") - .small() - .disabled(true) - .into_any_element() + /// 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() + .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 panel = panel.clone(); - let clicked = key.clone(); + let pair = cards.chunks(2).nth(ix - 1).unwrap_or(&[]); - 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}"); - } + 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() - }; - - h_flex() - .w_full() - .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(key.id().as_str().to_owned())), - ), - ) - .child(action) - .into_any_element() + } }) .collect() } -} -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() + fn render_card(&self, candidate: &GroupCandidate, cx: &mut Context) -> AnyElement { + 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); + + 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())); + + v_flex() + .id(SharedString::from(format!("card-{seed}"))) + .flex_1() + .min_w_0() + .h_full() + .overflow_hidden() + .rounded(cx.theme().radius) + .cursor_pointer() + .border_1() + .border_color(cx.theme().border_transparent) + .bg(cx.theme().surface_background) + .hover(|this| { + this.bg(cx.theme().elevated_surface_background) + .border_color(cx.theme().border_focused) + }) + .child( + v_flex() + .flex_1() + .min_h_0() + .p_2() + .gap_2() + .child( + h_flex() + .w_full() + .items_center() + .gap_2() + .when(!hide_avatar, |this| { + this.child(Avatar::from_source(picture).seed(seed).flex_shrink_0()) + }) + .child( + v_flex() + .flex_1() + .min_w_0() + .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) + .child(relay), + ), + ), + ) + .child( + div() + .min_w_0() + .text_sm() + .text_color(cx.theme().text_muted) + .text_ellipsis() + .line_clamp(2) + .map(|this| { + if let Some(about) = about { + this.child(about) + } else { + this.child(NO_DESCRIPTION) + } + }), + ) + .child(div().flex_1()) + .child( + h_flex() + .w_full() + .gap_1p5() + .text_color(cx.theme().text_muted) + .child(Icon::new(IconName::Book)) + .child( + div() + .text_sm() + .child(SharedString::from(format!("{members} members"))), + ), + ), + ) + .on_click(move |_event, window, cx| { + Self::open_panel(&dock, key.clone(), window, cx); + }) + .into_any_element() + } } impl Panel for BrowsePanel { @@ -248,7 +462,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() @@ -269,44 +484,67 @@ 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 body = if self.relay.is_none() { - notice("Enter a relay to browse its groups.", cx) - } else if count > 0 { - uniform_list( - "browse-groups", - count, - cx.processor(|this, range, _window, cx| this.render_rows(range, cx)), - ) - .h_full() - .into_any_element() - } else if self.loading { - notice("Loading groups…", cx) + let cards: Vec = if discover { + self.discovered_cards(cx) } else { - notice("No groups found.", cx) + self.candidates.clone() }; - v_flex() - .size_full() - .child( - h_flex() - .gap_2() - .p_3() - .flex_shrink_0() - .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); - })), - ) - .when(self.loading, |this| { - this.child(Indicator::new().small().color(cx.theme().icon_accent)) - }), + let rows = cards.len().div_ceil(2); + + 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( + view, + "groups", + Rc::new(item_sizes), + |this, range, _window, cx| this.render_list_rows(range, cx), ) - .child(div().flex_1().min_h_0().child(body)) + .track_scroll(&self.scroll_handle) + .h_full() + .into_any_element() + } else { + 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() + }; + + div() + .size_full() + .relative() + .child(content) + .child(Scrollbar::vertical(&self.scroll_handle)) } } + +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() +}