diff --git a/crates/community/src/discover.rs b/crates/community/src/discover.rs index 4cb35913..b70185ab 100644 --- a/crates/community/src/discover.rs +++ b/crates/community/src/discover.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::LazyLock; -use concord::cord05; +use concord::cord05::{self, CommunityInvite}; use nostr_sdk::prelude::*; use regex::Regex; @@ -15,21 +15,23 @@ static INVITE_URL: LazyLock = LazyLock::new(|| { }); #[derive(Debug, Clone)] -pub struct Invite { +pub struct Listing { pub invite_url: String, pub link_signer: PublicKey, - pub source: Event, + pub community: Option, + pub active_at: Option, } -impl Invite { - pub fn from_event(event: &Event) -> Option { +impl Listing { + pub fn from_event(event: &Event) -> Option { let invite_url = extract_invite_urls(&event.content).into_iter().next()?; let link_signer = cord05::parse_link(&invite_url).ok()?.link_signer; - Some(Invite { + Some(Listing { invite_url, link_signer, - source: event.clone(), + community: None, + active_at: None, }) } } @@ -71,7 +73,7 @@ pub fn build_announcement_deletion(ids: &[EventId]) -> EventBuilder { EventBuilder::new(Kind::EventDeletion, "").tags(tags) } -pub fn fold_announcements(announcements: &[Event], deletes: &[Event]) -> Vec { +pub fn fold_announcements(announcements: &[Event], deletes: &[Event]) -> Vec { let authors: BTreeMap = announcements .iter() .filter(|event| event.kind == Kind::Custom(KIND_COMMUNITY_ANNOUNCEMENT)) @@ -91,12 +93,12 @@ pub fn fold_announcements(announcements: &[Event], deletes: &[Event]) -> Vec SubscriptionId { SubscriptionId::new(format!("history-{}", NEXT.fetch_add(1, Ordering::Relaxed))) } -async fn within(limit: Duration, future: F) -> Option +pub(crate) async fn within(limit: Duration, future: F) -> Option where F: Future, { diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index 7e4feb89..afdaaf5d 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -23,12 +23,10 @@ mod rekey; mod sync; pub use community::*; +pub use discover::Listing; pub use sync::*; -/// How long a burst of relay notifications is collected before it is folded. const PUMP_WINDOW: Duration = Duration::from_millis(200); -/// How long a community's relays may deliver nothing before -/// its standing subscription is torn down and re-issued. const LIVE_ROTATE: Duration = Duration::from_secs(90); pub fn init(cx: &mut App) { @@ -143,24 +141,23 @@ pub struct CommunityRegistry { index: HashMap>, /// The plane set each community was last subscribed with synced: HashMap, - /// When a relay last delivered something for a community, - /// which is the only evidence the standing subscription is alive. + /// When a relay last delivered something for a community. last_event: HashMap, - /// One observer per tracked community, dropped on reset + /// One observer per tracked community, dropped on reset. observers: HashMap, - signal_tx: flume::Sender, - signal_rx: flume::Receiver, /// The page subscriptions in flight, shared with the notification pump. pages: PageRegistry, /// The rekey watch subscriptions, resolved to their community. watches: WatchRegistry, - tasks: SmallVec<[Task>; 2]>, + signal_tx: flume::Sender, + signal_rx: flume::Receiver, /// Notification listener task (cancelled on signer change) notification_listener: Option>>, /// Signal consumer task (cancelled on signer change) signal_consumer: Option>>, /// The round scheduler (cancelled on signer change) scheduler: Option>>, + tasks: SmallVec<[Task>; 2]>, _subscriptions: SmallVec<[Subscription; 2]>, } @@ -174,8 +171,9 @@ impl CommunityRegistry { } fn new(cx: &mut Context) -> Self { - let entity = cx.entity().downgrade(); let nostr = NostrRegistry::global(cx); + let entity = cx.entity().downgrade(); + let (tx, rx) = flume::bounded::(256); let mut subscriptions = smallvec![]; @@ -189,15 +187,14 @@ impl CommunityRegistry { })); cx.defer(move |cx| { - entity - .update(cx, |this, cx| { - this.handle_notifications(cx); - if nostr.read(cx).current_user().is_some() { - this.subscribe_list(cx); - this.load(cx); - } - }) - .ok(); + let _ = entity.update(cx, |this, cx| { + this.handle_notifications(cx); + + if nostr.read(cx).current_user().is_some() { + this.subscribe_list(cx); + this.load(cx); + } + }); }); Self { @@ -637,8 +634,6 @@ impl CommunityRegistry { while let Ok(signal) = rx.recv_async().await { match signal { Signal::Event(id) => this.update(cx, |this, cx| { - // The only proof a community's subscription is still - // delivering anything. this.last_event.insert(id, Instant::now()); this.refresh(id, cx); })?, diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index f2172ae0..a7e71e94 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -1,6 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; -use std::time::Duration; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use concord::cord01::KIND_WRAP_EPHEMERAL; @@ -20,10 +21,11 @@ use concord::{ChannelId, CommunityId, Epoch, GroupKey, decode_hex_32}; use data_encoding::HEXLOWER; use gpui::AsyncApp; use nostr_sdk::prelude::*; -use state::UniversalSigner; +use state::{BOOTSTRAP_RELAYS, UniversalSigner}; use crate::cache::{self, Observed}; -use crate::history::{CURSOR_OVERLAP, Window}; +use crate::discover::{self, Listing}; +use crate::history::{CURSOR_OVERLAP, Window, within}; /// How much of what a relay stores a cold subscription replays per relay. const LIVE_REPLAY: usize = 500; @@ -274,7 +276,7 @@ where S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, { let link = cord05::parse_link(url)?; - let invite = resolve_invite(client, &link).await?; + let invite = resolve_bundle(client, &link, &[]).await?; let material = cord02::list::join_material(&invite, None); let self_pk = signer.get_public_key_async().await?; @@ -299,36 +301,34 @@ where Ok(state) } -async fn resolve_invite(client: &Client, link: &ParsedInviteLink) -> Result { - let relays: Vec = link +pub(crate) async fn resolve_bundle( + client: &Client, + link: &ParsedInviteLink, + fallback: &[RelayUrl], +) -> Result { + let mut relays: Vec = link .bootstrap_relays .iter() .filter_map(|relay| RelayUrl::parse(relay).ok()) .collect(); if relays.is_empty() { - anyhow::bail!("invite link names no relays"); + relays = fallback.to_vec(); } - connect_relays(client, &relays).await; + if relays.is_empty() { + anyhow::bail!("invite link names no relays"); + } let filter = Filter::new() .kind(Kind::Custom(cord05::KIND_BUNDLE)) .author(link.link_signer) .identifier(""); - let targets: Vec<(RelayUrl, Vec)> = relays - .into_iter() - .map(|relay| (relay, vec![filter.clone()])) - .collect(); - - let events = client - .fetch_events(targets) - .timeout(Duration::from_secs(10)) - .await?; + let events = read_events(client, &relays, std::slice::from_ref(&filter)).await?; let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else { - anyhow::bail!("invite link not found on its relays"); + anyhow::bail!("invite bundle not found on its relays"); }; let bundle_key = invite_bundle_key(&link.token); @@ -392,6 +392,325 @@ where Ok(merged) } +const DISCOVER_LIMIT: usize = 100; +const ACTIVITY_FILTERS_PER_REQ: usize = 20; + +pub(crate) struct ActivityTarget { + pub link_signer: PublicKey, + pub authors: Vec, + pub relays: Vec, +} + +pub(crate) async fn discover_relays(client: &Client, self_pk: Option) -> Vec { + let mut relays: BTreeSet = BOOTSTRAP_RELAYS + .into_iter() + .filter_map(|url| RelayUrl::parse(url).ok()) + .collect(); + + if let Some(self_pk) = self_pk { + let filter = Filter::new().kind(Kind::RelayList).author(self_pk).limit(1); + + if let Ok(events) = client.database().query(filter).await + && let Some(event) = events.into_iter().max_by_key(|event| event.created_at) + { + for (url, metadata) in nip65::extract_relay_list(&event) { + if metadata != Some(RelayMetadata::Write) { + relays.insert(url); + } + } + } + } + + relays.into_iter().collect() +} + +pub async fn discover_page(client: &Client, self_pk: Option) -> Result> { + let relays = discover_relays(client, self_pk).await; + + if relays.is_empty() { + return Ok(Vec::new()); + } + + let (announcements, deletes) = read_announcements(client, &relays).await?; + let invites = discover::fold_announcements(&announcements, &deletes); + + let mut listings = futures::future::join_all(invites.into_iter().map(|mut listing| { + let fallback = relays.clone(); + + async move { + listing.community = match cord05::parse_link(&listing.invite_url) { + Ok(link) => resolve_bundle(client, &link, &fallback).await.ok(), + Err(_) => None, + }; + + listing + } + })) + .await; + + let targets: Vec = listings.iter().filter_map(activity_target).collect(); + + if !targets.is_empty() { + for (link_signer, at) in probe_activity(client, &targets).await { + if let Some(listing) = listings + .iter_mut() + .find(|listing| listing.link_signer == link_signer) + { + listing.active_at = Some(at); + } + } + } + + Ok(listings) +} + +fn activity_target(listing: &Listing) -> Option { + let community = listing.community.as_ref()?; + let authors = activity_authors(community); + + if authors.is_empty() { + return None; + } + + Some(ActivityTarget { + link_signer: listing.link_signer, + authors, + relays: community + .relays + .iter() + .filter_map(|relay| RelayUrl::parse(relay).ok()) + .collect(), + }) +} + +fn activity_authors(invite: &CommunityInvite) -> Vec { + let Ok(root) = decode_hex_32(&invite.community_root) else { + return Vec::new(); + }; + + let mut authors = Vec::new(); + + if let Ok(group) = guestbook_group_key(&root, &invite.community_id, invite.root_epoch) { + authors.push(group.pk()); + } + + match invite.control_pk { + Some(control_pk) => authors.push(control_pk), + None => { + if let Ok(group) = control_group_key(&root, &invite.community_id, invite.root_epoch) { + authors.push(group.pk()); + } + } + } + + for channel in &invite.channels { + if let Some(key) = &channel.key + && let Ok(key) = decode_hex_32(key) + && let Ok(group) = channel_group_key(&key, &channel.id, channel.epoch) + { + authors.push(group.pk()); + } + } + + authors.sort(); + authors.dedup(); + authors +} + +async fn read_announcements( + client: &Client, + relays: &[RelayUrl], +) -> Result<(Vec, Vec)> { + let filters = vec![ + Filter::new() + .kind(Kind::Custom(discover::KIND_COMMUNITY_ANNOUNCEMENT)) + .limit(DISCOVER_LIMIT), + Filter::new() + .kind(Kind::EventDeletion) + .custom_tag( + SingleLetterTag::LOWERCASE_K, + discover::KIND_COMMUNITY_ANNOUNCEMENT.to_string(), + ) + .limit(DISCOVER_LIMIT), + ]; + + let events = read_events(client, relays, &filters).await?; + + let mut found = Vec::new(); + let mut deleted = Vec::new(); + + for event in events { + if event.kind == Kind::Custom(discover::KIND_COMMUNITY_ANNOUNCEMENT) { + found.push(event); + } else if event.kind == Kind::EventDeletion { + deleted.push(event); + } + } + + Ok((found, deleted)) +} + +async fn probe_activity( + client: &Client, + targets: &[ActivityTarget], +) -> Vec<(PublicKey, Timestamp)> { + let now = Timestamp::now(); + + let mut by_author: BTreeMap> = BTreeMap::new(); + let mut groups: BTreeMap, Vec<&ActivityTarget>> = BTreeMap::new(); + + for target in targets { + if target.relays.is_empty() { + continue; + } + + for author in &target.authors { + by_author + .entry(*author) + .or_default() + .insert(target.link_signer); + } + + groups + .entry(target.relays.clone()) + .or_default() + .push(target); + } + + let mut newest: BTreeMap = BTreeMap::new(); + + for (relays, group) in groups { + for chunk in group.chunks(ACTIVITY_FILTERS_PER_REQ) { + let filters: Vec = chunk + .iter() + .map(|target| { + Filter::new() + .kind(Kind::GiftWrap) + .authors(target.authors.iter().copied()) + .until(now) + .limit(1) + }) + .collect(); + + let events = match read_events(client, &relays, &filters).await { + Ok(events) => events, + Err(error) => { + log::warn!("discover: activity probe failed: {error}"); + continue; + } + }; + + for event in events { + if event.created_at > now { + continue; + } + + if let Some(signers) = by_author.get(&event.pubkey) { + for signer in signers { + let entry = newest.entry(*signer).or_insert(event.created_at); + + if event.created_at > *entry { + *entry = event.created_at; + } + } + } + } + } + } + + newest.into_iter().collect() +} + +async fn pool_targets( + client: &Client, + relays: &[RelayUrl], + filters: &[Filter], +) -> Vec<(RelayUrl, Vec)> { + let mut targets = Vec::with_capacity(relays.len()); + + for url in relays { + if let Ok(Some(_)) = client.relay(url).await { + targets.push((url.clone(), filters.to_vec())); + } + } + + targets +} + +const READ_TIMEOUT: Duration = Duration::from_secs(10); + +fn read_subscription() -> SubscriptionId { + static NEXT: AtomicU64 = AtomicU64::new(0); + SubscriptionId::new(format!("read-{}", NEXT.fetch_add(1, Ordering::Relaxed))) +} + +fn settled_subscription<'m>(message: &'m RelayMessage<'_>) -> Option<&'m SubscriptionId> { + match message { + RelayMessage::EndOfStoredEvents(id) => Some(id), + RelayMessage::Closed { + subscription_id, .. + } => Some(subscription_id), + _ => None, + } +} + +async fn read_events( + client: &Client, + relays: &[RelayUrl], + filters: &[Filter], +) -> Result> { + connect_relays(client, relays).await; + + let targets = pool_targets(client, relays, filters).await; + + if targets.is_empty() { + anyhow::bail!("no relay is reachable"); + } + + let expected: BTreeSet = targets.iter().map(|(url, _)| url.clone()).collect(); + let id = read_subscription(); + let mut notifications = client.notifications(); + + let options = SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE) + .timeout(Some(READ_TIMEOUT)); + + let output = client + .subscribe(ReqTarget::manual(targets)) + .with_id(id.clone()) + .close_on(options) + .await?; + + let deadline = Instant::now() + READ_TIMEOUT; + let mut settled: BTreeSet = output.failed.into_keys().collect(); + + while settled.len() < expected.len() { + let remaining = deadline.saturating_duration_since(Instant::now()); + + let Some(notification) = within(remaining, notifications.next()).await.flatten() else { + break; + }; + + match notification { + ClientNotification::Message { relay_url, message } + if settled_subscription(&message) == Some(&id) => + { + settled.insert(relay_url); + } + ClientNotification::Shutdown => break, + _ => {} + } + } + + let mut events = BTreeSet::new(); + + for filter in filters { + events.extend(client.database().query(filter.clone()).await?); + } + + Ok(events.into_iter().collect()) +} + /// Best-effort publication of the genesis wraps to the community's relays. pub(crate) async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) { for wrap in wraps { diff --git a/crates/concord/src/cords/cord05.rs b/crates/concord/src/cords/cord05.rs index f4cb92d8..d6d708b3 100644 --- a/crates/concord/src/cords/cord05.rs +++ b/crates/concord/src/cords/cord05.rs @@ -129,7 +129,6 @@ pub struct CommunityInvite { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub icon: Option, - /// Unix **ms**: past it the preview still renders, joining refuses. #[serde(default, skip_serializing_if = "Option::is_none")] pub expires_at: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/workspace/src/panels/browse.rs b/crates/workspace/src/panels/browse.rs index 163766b9..0d7c2f29 100644 --- a/crates/workspace/src/panels/browse.rs +++ b/crates/workspace/src/panels/browse.rs @@ -1,10 +1,26 @@ +use std::collections::HashMap; +use std::ops::Range; +use std::path::PathBuf; + +use anyhow::Result; +use common::TimestampExt; +use community::{CommunityRegistry, Listing}; +use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - IntoElement, ParentElement, Render, SharedString, Styled, Window, + IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, + uniform_list, }; +use nostr_sdk::prelude::*; +use smallvec::{SmallVec, smallvec}; +use state::NostrRegistry; use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; -use ui::{Icon, IconName, Sizable, h_flex}; +use ui::input::{Input, InputEvent, InputState}; +use ui::skeleton::Skeleton; +use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| BrowsePanel::new(window, cx)) @@ -13,15 +29,229 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { pub struct BrowsePanel { name: SharedString, focus_handle: FocusHandle, + search: Entity, + listings: Option>, + icons: HashMap, + loading: bool, + error: Option, + tasks: SmallVec<[Task>; 2]>, + _subscriptions: SmallVec<[Subscription; 1]>, } impl BrowsePanel { - fn new(_window: &mut Window, cx: &mut App) -> Self { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search communities")); + + let mut subscriptions = smallvec![]; + + subscriptions.push(cx.subscribe_in( + &search, + window, + |_this, _state, event, _window, cx| { + if let InputEvent::Change = event { + cx.notify(); + } + }, + )); + + cx.defer_in(window, |this, _window, cx| this.load(cx)); + Self { name: "Browse".into(), focus_handle: cx.focus_handle(), + search, + listings: None, + icons: HashMap::new(), + loading: false, + error: None, + tasks: smallvec![], + _subscriptions: subscriptions, } } + + fn load(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let self_pk = nostr.read(cx).current_user(); + + self.loading = true; + self.error = None; + self.icons.clear(); + cx.notify(); + + let task = + cx.background_spawn(async move { community::discover_page(&client, self_pk).await }); + + self.tasks.push(cx.spawn(async move |this, cx| { + match task.await { + Ok(listings) => this.update(cx, |this, cx| { + this.loading = false; + this.listings = Some(listings); + this.load_icons(cx); + cx.notify(); + })?, + Err(error) => this.update(cx, |this, cx| { + this.loading = false; + this.error = Some(error.to_string().into()); + cx.notify(); + })?, + } + + Ok(()) + })); + } + + fn load_icons(&mut self, cx: &mut Context) { + let icons: Vec<_> = self + .listings + .iter() + .flatten() + .filter_map(|listing| { + let icon = listing.community.as_ref()?.icon.clone()?; + Some((listing.link_signer, icon)) + }) + .collect(); + + for (signer, icon) in icons { + if self.icons.contains_key(&signer) { + continue; + } + + self.tasks.push(cx.spawn(async move |this, cx| { + if let Ok(path) = community::resolve_image(&icon, cx).await { + this.update(cx, |this, cx| { + this.icons.insert(signer, path); + cx.notify(); + })?; + } + + Ok(()) + })); + } + } + + fn visible<'a>(&'a self, cx: &App) -> Vec<&'a Listing> { + let query = self.search.read(cx).value().trim().to_lowercase(); + + self.listings + .iter() + .flatten() + .filter(|listing| { + query.is_empty() || card_name(listing).to_lowercase().contains(&query) + }) + .collect() + } + + fn render_loading(&self, cx: &App) -> impl IntoElement { + v_flex().gap_2().children((0..6).map(|_| { + h_flex() + .gap_3() + .p_3() + .rounded_lg() + .bg(cx.theme().elevated_surface_background) + .child(Skeleton::new().w_10().h_10().rounded_full().flex_shrink_0()) + .child( + v_flex() + .flex_1() + .justify_center() + .gap_2() + .child(Skeleton::new().w_40().h_4()) + .child(Skeleton::new().w_24().h_3()), + ) + })) + } + + fn render_cards(&self, range: Range, cx: &Context) -> Vec { + self.visible(cx) + .get(range) + .into_iter() + .flatten() + .copied() + .map(|listing| render_card(listing, self.icons.get(&listing.link_signer), cx)) + .collect() + } +} + +fn card_name(listing: &Listing) -> SharedString { + match listing.community.as_ref() { + Some(community) => community.name.clone().into(), + None => "Encrypted community".into(), + } +} + +fn render_card(listing: &Listing, icon: Option<&PathBuf>, cx: &App) -> AnyElement { + let mut details: Vec = Vec::new(); + + if let Some(community) = listing.community.as_ref() + && !community.channels.is_empty() + { + details.push(format!("{} channels", community.channels.len()).into()); + } + + if let Some(active_at) = listing.active_at { + details.push(format!("Active {}", active_at.to_ago()).into()); + } + + let invite_url = listing.invite_url.clone(); + + h_flex() + .gap_3() + .p_3() + .rounded_lg() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().elevated_surface_background) + .child( + Avatar::from_source(icon.cloned()) + .seed(listing.link_signer.to_hex()) + .small(), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .child( + div() + .text_sm() + .font_medium() + .truncate() + .child(card_name(listing)), + ) + .when(!details.is_empty(), |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .truncate() + .child(details.join(" ยท ")), + ) + }), + ) + .child( + Button::new(format!("join-{}", listing.link_signer.to_hex())) + .label("Join") + .primary() + .on_click(move |_event, _window, cx: &mut App| { + let registry = CommunityRegistry::global(cx); + registry.update(cx, |registry, cx| registry.join(invite_url.clone(), cx)); + }), + ) + .into_any_element() +} + +fn render_message(icon: IconName, message: SharedString, cx: &App) -> impl IntoElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .gap_2() + .child(Icon::new(icon).large().text_color(cx.theme().icon_muted)) + .child( + div() + .text_sm() + .text_color(cx.theme().text_muted) + .child(message), + ) } impl Panel for BrowsePanel { @@ -52,11 +282,51 @@ impl Focusable for BrowsePanel { impl Render for BrowsePanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - h_flex() + let header = h_flex() + .gap_2() + .child(div().flex_1().child(Input::new(&self.search).small())) + .child( + Button::new("refresh") + .icon(IconName::Refresh) + .tooltip("Refresh") + .ghost() + .on_click(cx.listener(|this, _event, _window, cx| this.load(cx))), + ); + + let body = if self.loading && self.listings.is_none() { + self.render_loading(cx).into_any_element() + } else if let Some(error) = self.error.clone() + && self.listings.is_none() + { + render_message(IconName::Warning, error, cx).into_any_element() + } else { + let visible = self.visible(cx).len(); + let empty = self.listings.as_ref().is_none_or(Vec::is_empty); + + if visible == 0 { + let message = if empty { + "No communities are listed yet".into() + } else { + "No community matches your search".into() + }; + + render_message(IconName::Compass, message, cx).into_any_element() + } else { + uniform_list( + "browse-listings", + visible, + cx.processor(|this, range, _window, cx| this.render_cards(range, cx)), + ) + .flex_1() + .into_any_element() + } + }; + + v_flex() .size_full() - .justify_center() - .text_sm() - .text_color(cx.theme().text_muted) - .child(self.name.clone()) + .gap_3() + .p_3() + .child(header) + .child(div().flex_1().min_h_0().child(body)) } }