add community browse panel

This commit is contained in:
2026-09-29 20:02:36 +07:00
parent c6dd2bb1ce
commit b0674774db
6 changed files with 645 additions and 60 deletions
+278 -8
View File
@@ -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<BrowsePanel> {
cx.new(|cx| BrowsePanel::new(window, cx))
@@ -13,15 +29,229 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
pub struct BrowsePanel {
name: SharedString,
focus_handle: FocusHandle,
search: Entity<InputState>,
listings: Option<Vec<Listing>>,
icons: HashMap<PublicKey, PathBuf>,
loading: bool,
error: Option<SharedString>,
tasks: SmallVec<[Task<Result<()>>; 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>) -> 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<Self>) {
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<Self>) {
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<usize>, cx: &Context<Self>) -> Vec<AnyElement> {
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<SharedString> = 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<Self>) -> 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))
}
}