Files
coop/crates/workspace/src/panels/browse.rs
T

573 lines
19 KiB
Rust

use std::ops::Range;
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, ScrollStrategy, SharedString,
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, px, relative,
size,
};
use gpui_base::{VirtualListScrollHandle, v_virtual_list};
use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry};
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::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 HEADER_HEIGHT: f32 = 96.0;
const CARD_HEIGHT: f32 = 164.0;
const NO_DESCRIPTION: &str = "No description";
pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
cx.new(|cx| BrowsePanel::new(dock, window, cx))
}
pub struct BrowsePanel {
focus_handle: FocusHandle,
dock: WeakEntity<DockArea>,
/// The relay input
input: Entity<InputState>,
/// The relay currently being browsed.
relay: Option<RelayUrl>,
/// The groups the relay has announced so far
candidates: Vec<GroupCandidate>,
/// 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]>,
}
impl BrowsePanel {
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder(INPUT_PLACEHOLDER));
let registry = GroupsRegistry::global(cx);
let mut subscriptions = smallvec![];
subscriptions.push(
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.browse(window, cx);
}
}),
);
subscriptions.push(
cx.subscribe(&registry, |this, _registry, event, cx| match event {
GroupsEvent::Browsed(candidate) => {
this.push_candidate(candidate.as_ref().clone(), cx)
}
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<Self>) {
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>) {
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>,
) {
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<Self>) {
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<Self>) {
let registry = GroupsRegistry::global(cx);
let value = self.input.read(cx).value().trim().to_owned();
if value.is_empty() {
return;
}
let relay = match RelayUrl::parse(&value) {
Ok(relay) => relay,
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx);
return;
}
};
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);
});
cx.notify();
}
fn push_candidate(&mut self, candidate: GroupCandidate, cx: &mut Context<Self>) {
if self.relay.as_ref() != Some(candidate.key.relay()) {
return;
}
match self.candidates.iter_mut().find(|e| e.key == candidate.key) {
Some(existing) => *existing = candidate,
None => self.candidates.push(candidate),
}
cx.notify();
}
/// The relay stopped answering: EOSE, a refusal, or the auto-close timeout.
fn settle(&mut self, relay: RelayUrl, cx: &mut Context<Self>) {
if self.relay.as_ref() != Some(&relay) {
return;
}
self.loading = false;
cx.notify();
}
/// Join a browsed group and open it.
fn join(dock: &WeakEntity<DockArea>, 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;
};
ui::dock::add_panel_to(
dock,
PanelHandle::new(nip29_ui::init(group, window, cx)),
DockPlacement::Center,
window,
cx,
);
}
/// The discovered groups the user has not joined yet.
fn discovered_cards(&self, cx: &App) -> Vec<GroupCandidate> {
let joined: Vec<GroupKey> = GroupsRegistry::global(cx)
.read(cx)
.groups()
.iter()
.map(|group| group.read(cx).key().clone())
.collect();
self.discovery
.discovered()
.iter()
.filter(|candidate| !joined.contains(&candidate.key))
.cloned()
.collect()
}
/// The panel header, item 0 of the list so it scrolls with the cards.
fn render_header(&self, cx: &Context<Self>) -> 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<usize>, cx: &mut Context<Self>) -> Vec<AnyElement> {
let discover = self.relay.is_none();
let cards: Vec<GroupCandidate> = if discover {
self.discovered_cards(cx)
} else {
self.candidates.clone()
};
range
.map(|ix| {
if ix == 0 {
self.render_header(cx)
} else {
let pair = cards.chunks(2).nth(ix - 1).unwrap_or(&[]);
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()
}
})
.collect()
}
fn render_card(&self, candidate: &GroupCandidate, cx: &mut Context<Self>) -> AnyElement {
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()));
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!("joined-{seed}"))
.label("Joined")
.small()
.ghost()
.disabled(true)
.into_any_element()
} else {
let panel = cx.entity().downgrade();
let dock = self.dock.clone();
let clicked = key;
Button::new(format!("join-{seed}"))
.label("Join")
.small()
.ghost()
.on_click(move |_event, window, cx| {
Self::join(&dock, clicked.clone(), window, cx);
let _ = panel.update(cx, |_, cx| cx.notify());
})
.into_any_element()
};
v_flex()
.flex_1()
.min_w_0()
.h_full()
.overflow_hidden()
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius)
.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(action),
)
.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"))),
),
),
)
.into_any_element()
}
}
impl Panel for BrowsePanel {
fn panel_id(&self) -> SharedString {
TITLE.into()
}
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1()
.text_xs()
.child(
Icon::new(IconName::Compass)
.small()
.text_color(cx.theme().icon_muted),
)
.child(TITLE)
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for BrowsePanel {}
impl Focusable for BrowsePanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for BrowsePanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let discover = self.relay.is_none();
let cards: Vec<GroupCandidate> = if discover {
self.discovered_cards(cx)
} else {
self.candidates.clone()
};
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),
)
.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()
}