add community browse panel
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user