diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index 663d3d5b..18384202 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -35,17 +35,12 @@ struct GlobalGroupsRegistry(Entity); impl Global for GlobalGroupsRegistry {} -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] pub enum GroupsEvent { Updated, Error(String), -} - -#[derive(Debug, Clone)] -pub struct Browse { - pub relay: RelayUrl, - pub loading: bool, - pub groups: Vec, + Browsed(Box), + BrowseSettled(RelayUrl), } #[derive(Debug)] @@ -53,6 +48,7 @@ enum Signal { Event(SubscriptionId, Event), Eose(SubscriptionId), Closed(SubscriptionId, String), + BrowseSettled(SubscriptionId), Forks, } @@ -97,7 +93,7 @@ impl EventEmitter for GroupsRegistry {} pub struct GroupsRegistry { groups: Vec>, list: GroupList, - browse: HashMap, + browse: HashMap, routes: HashMap, observers: HashMap, forks: HashMap, @@ -246,19 +242,11 @@ impl GroupsRegistry { pub fn browse(&mut self, relay: RelayUrl, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); + let id = browse_subscription(&relay); + let tx = self.signal_tx.clone(); - self.browse.insert( - id.clone(), - Browse { - relay: relay.clone(), - loading: true, - groups: Vec::new(), - }, - ); - - cx.emit(GroupsEvent::Updated); - cx.notify(); + self.browse.insert(id.clone(), relay.clone()); self.tasks.push(cx.background_spawn(async move { client.add_relay(relay.clone()).and_connect().await.ok(); @@ -269,18 +257,25 @@ impl GroupsRegistry { .timeout(Some(BROWSE_TIMEOUT)); if let Err(error) = client - .subscribe(ReqTarget::single(relay, [filter])) - .with_id(id) + .subscribe(ReqTarget::single(relay.clone(), [filter])) + .with_id(id.clone()) .close_on(opts) .await { - log::warn!("nip29: browsing failed: {error}"); + log::warn!("nip29: 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}"); } })); } - pub fn browsed(&self, relay: &RelayUrl) -> Option<&Browse> { - self.browse.get(&browse_subscription(relay)) + fn settle_browse(&mut self, id: SubscriptionId, cx: &mut Context) { + let Some(relay) = self.browse.get(&id).cloned() else { + return; + }; + cx.emit(GroupsEvent::BrowseSettled(relay)); } pub fn load_more(&mut self, key: &GroupKey, cx: &mut Context) { @@ -516,6 +511,9 @@ impl GroupsRegistry { GroupEvent::Updated => { this.refresh_entry(group, cx); this.sync_forks(false, cx); + + cx.emit(GroupsEvent::Updated); + cx.notify(); } _ => {} }), @@ -611,9 +609,6 @@ impl GroupsRegistry { self.upsert_entry(&key, Some(name)); self.publish_list(cx); - - cx.emit(GroupsEvent::Updated); - cx.notify(); } fn publish_list(&mut self, cx: &mut Context) { @@ -741,15 +736,12 @@ impl GroupsRegistry { } fn absorb_candidate(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context) { - let Some(browse) = self.browse.get_mut(id) else { + let Some(relay) = self.browse.get(id).cloned() else { return; }; let Ok(metadata) = GroupMetadata::parse(event) else { - log::warn!( - "nip29: ignoring malformed group metadata from {}", - browse.relay - ); + log::warn!("nip29: ignoring malformed group metadata from {relay}"); return; }; @@ -757,28 +749,13 @@ impl GroupsRegistry { return; }; - let candidate = GroupCandidate { - key: GroupKey::new(browse.relay.clone(), group), + cx.emit(GroupsEvent::Browsed(Box::new(GroupCandidate { + key: GroupKey::new(relay, group), metadata, - }; - - match browse.groups.iter_mut().find(|e| e.key == candidate.key) { - Some(existing) => *existing = candidate, - None => browse.groups.push(candidate), - } - - cx.emit(GroupsEvent::Updated); - cx.notify(); + }))); } fn handle_eose(&mut self, id: SubscriptionId, cx: &mut Context) { - if let Some(browse) = self.browse.get_mut(&id) { - browse.loading = false; - cx.emit(GroupsEvent::Updated); - cx.notify(); - return; - } - let Some(key) = self.routes.get(&id).cloned() else { return; }; @@ -807,12 +784,8 @@ impl GroupsRegistry { } fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context) { - if let Some(browse) = self.browse.get_mut(&id) { - log::warn!("nip29: browsing {} was refused: {reason}", browse.relay); - browse.loading = false; - - cx.emit(GroupsEvent::Updated); - cx.notify(); + if let Some(relay) = self.browse.get(&id) { + log::warn!("nip29: browsing {relay} was refused: {reason}"); return; } @@ -922,6 +895,9 @@ impl GroupsRegistry { Signal::Closed(id, reason) => { this.handle_closed(id, reason, cx); } + Signal::BrowseSettled(id) => { + this.settle_browse(id, cx); + } Signal::Forks => { this.sync_forks(true, cx); } diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index a5647a74..0e7fc91e 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -469,6 +469,18 @@ pub struct GroupCandidate { pub metadata: GroupMetadata, } +impl GroupCandidate { + pub fn display_name(&self) -> &str { + self.metadata + .name() + .unwrap_or_else(|| self.key.id().as_str()) + } + + pub fn display_image(&self) -> Option<&str> { + self.metadata.picture.as_deref() + } +} + #[derive(Debug, Clone, Default)] pub struct GroupAdmins(Vec<(PublicKey, Vec)>); diff --git a/crates/workspace/src/dialogs/join_group.rs b/crates/workspace/src/dialogs/join_group.rs index da7eb4fe..07c58d06 100644 --- a/crates/workspace/src/dialogs/join_group.rs +++ b/crates/workspace/src/dialogs/join_group.rs @@ -1,4 +1,4 @@ -use gpui::{App, AppContext, ParentElement, Window, px}; +use gpui::{App, AppContext, ParentElement, Styled, Window, px}; use nip29::{GroupReference, GroupsRegistry}; use ui::WindowExtension; use ui::input::{Input, InputState}; @@ -12,9 +12,11 @@ pub fn open(window: &mut Window, cx: &mut App) { this.width(px(380.)) .confirm() - .title("Join group") - .child(Input::new(&input)) + .title("Join Group") + .child(Input::new(&input).cleanable(true).flex_1().text_sm()) .on_ok(move |_event, window, cx| { + let groups = GroupsRegistry::global(cx); + let reference = match GroupReference::parse(input.read(cx).value().trim()) { Ok(reference) => reference, Err(error) => { @@ -23,7 +25,7 @@ pub fn open(window: &mut Window, cx: &mut App) { } }; - GroupsRegistry::global(cx).update(cx, |registry, cx| { + groups.update(cx, |registry, cx| { registry.join(reference, cx); }); diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 29d6e4b6..f09eff4d 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -283,7 +283,8 @@ impl Workspace { ); } Command::ShowBrowse => { - self.add_panel_to_dock(browse::init(window, cx), DockPlacement::Center, window, cx); + let panel = browse::init(self.dock.downgrade(), window, cx); + self.add_panel_to_dock(panel, DockPlacement::Center, window, cx); } Command::ShowSearch => { let panel = search::init(self.dock.downgrade(), window, cx); diff --git a/crates/workspace/src/panels/browse.rs b/crates/workspace/src/panels/browse.rs index 163766b9..ce39a236 100644 --- a/crates/workspace/src/panels/browse.rs +++ b/crates/workspace/src/panels/browse.rs @@ -1,32 +1,249 @@ +use std::ops::Range; + +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, WeakEntity, Window, + div, uniform_list, }; +use nip29::{GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry}; +use nostr_sdk::prelude::RelayUrl; +use settings::AppSettings; +use smallvec::{SmallVec, smallvec}; use theme::ActiveTheme; -use ui::dock::{Panel, PanelEvent}; -use ui::{Icon, IconName, Sizable, h_flex}; +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}; -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| BrowsePanel::new(window, cx)) +const TITLE: &str = "Browse"; +const INPUT_PLACEHOLDER: &str = "wss://relay.example.com"; + +pub fn init(dock: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| BrowsePanel::new(dock, window, cx)) } pub struct BrowsePanel { - name: SharedString, focus_handle: FocusHandle, + dock: WeakEntity, + /// The relay input + input: Entity, + /// The relay currently being browsed. + relay: Option, + /// The groups the relay has announced so far + candidates: Vec, + /// Whether the relay is still answering + loading: bool, + _subscriptions: SmallVec<[Subscription; 2]>, } impl BrowsePanel { - fn new(_window: &mut Window, cx: &mut App) -> Self { + fn new(dock: WeakEntity, window: &mut Window, cx: &mut Context) -> 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(®istry, |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); + } + _ => {} + }), + ); + Self { - name: "Browse".into(), focus_handle: cx.focus_handle(), + dock, + input, + relay: None, + candidates: Vec::new(), + loading: false, + _subscriptions: subscriptions, } } + + /// Browse the relay in the input. + fn browse(&mut self, window: &mut Window, cx: &mut Context) { + 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; + + registry.update(cx, |registry, cx| { + registry.browse(relay, cx); + }); + + cx.notify(); + } + + fn push_candidate(&mut self, candidate: GroupCandidate, cx: &mut Context) { + 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) { + if self.relay.as_ref() != Some(&relay) { + return; + } + self.loading = false; + 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(); + 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, + ); + } + + 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 + .read(cx) + .groups() + .iter() + .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); + + let action = if is_joined { + Button::new(format!("browse-joined-{seed}")) + .label("Joined") + .small() + .disabled(true) + .into_any_element() + } else { + let panel = panel.clone(); + let clicked = key.clone(); + + 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}"); + } + }) + .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() } impl Panel for BrowsePanel { fn panel_id(&self) -> SharedString { - self.name.clone() + TITLE.into() } fn title(&self, cx: &App) -> AnyElement { @@ -37,7 +254,7 @@ impl Panel for BrowsePanel { .small() .text_color(cx.theme().icon_muted), ) - .child(self.name.clone()) + .child(TITLE) .into_any_element() } } @@ -52,11 +269,44 @@ impl Focusable for BrowsePanel { impl Render for BrowsePanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - h_flex() + let count = self.candidates.len(); + + 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) + } else { + notice("No groups found.", cx) + }; + + v_flex() .size_full() - .justify_center() - .text_sm() - .text_color(cx.theme().text_muted) - .child(self.name.clone()) + .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)) + }), + ) + .child(div().flex_1().min_h_0().child(body)) } } diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 0c987c35..fdce0b25 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -98,9 +98,16 @@ impl Sidebar { subscriptions.push( cx.subscribe_in(&groups, window, |_this, _, event, window, cx| { - if let GroupsEvent::Error(error) = event { - window.push_notification(Notification::error(error.clone()), cx); - } + match event { + GroupsEvent::Updated => { + // TODO: find a better way? + cx.notify(); + } + GroupsEvent::Error(error) => { + window.push_notification(Notification::error(error.clone()), cx); + } + _ => {} + }; }), ); diff --git a/docs/nip29-usage.md b/docs/nip29-usage.md index 98987c28..d343199d 100644 --- a/docs/nip29-usage.md +++ b/docs/nip29-usage.md @@ -58,16 +58,20 @@ subscribes to `GroupsEvent` and renders `groups()` — pending and refused group are in that list alongside members, and their `Group::membership()` says which. ```rust -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] pub enum GroupsEvent { Updated, Error(String), + Browsed(Box), + BrowseSettled(RelayUrl), } ``` -`Updated` fires on any change to `groups()` or to a browse result; `Error` is only -emitted when publishing a `9007` (create) is refused, and carries the relay's -message. +`Updated` fires on any change to `groups()`; `Error` is only emitted when +publishing a `9007` (create) is refused, and carries the relay's message. +`Browsed` and `BrowseSettled` carry a browse: one group the relay announced, and +the end of the answer (see [Browsing a relay](#browsing-a-relay)). They are +fan-out, not stored — whatever opened the browse owns the candidates. ## Value types @@ -435,23 +439,35 @@ check runs with the relay gone, just at the next tick. ## Browsing a relay ```rust -use nip29::Browse; - registry.update(cx, |registry, cx| registry.browse(relay_url.clone(), cx)); -let browse: Option<&Browse> = registry.read(cx).browsed(&relay_url); + +// then, in a GroupsEvent handler: +match event { + GroupsEvent::Browsed(candidate) => push(candidate), + GroupsEvent::BrowseSettled(relay) => settled(relay), + _ => {} +} ``` -`Browse` is `{ relay, loading, groups: Vec }`, where a candidate -is a `GroupKey` plus its parsed `GroupMetadata`. `browse` connects the relay, -sends one auto-closing `39000` REQ (limit 500, EOSE or 10s) and returns -immediately; the pump folds each relay-signed metadata event into the result. Read -it back in a `GroupsEvent::Updated` handler, which also fires when the browse -settles (`loading` flips false on EOSE or a refusal). Join a result with -`join_key(candidate.key, None, cx)`. +A `GroupCandidate` is a `GroupKey` plus its parsed `GroupMetadata`; `display_name` +falls back to the id and `display_image` is the picture, the same pair `Group` +offers. `browse` connects the relay, sends one auto-closing `39000` REQ (limit +500, EOSE or 10s) and returns immediately; the pump parses each relay-signed +metadata event and re-emits it as `Browsed`. -One caveat: `Browse::loading` can stick if the relay accepts the REQ and never -EOSEs, because the auto-close timeout closes the subscription locally with no -notification. Treat it as "still trying", not as an error. +**The registry stores no browse result.** It keeps only which relay each `brw-` +REQ belongs to — the same routing bookkeeping as `routes`, and the reason +`Browsed` can carry a boxed candidate instead of the registry holding a `Vec`: +the only copy is the caller's. A browse is transient by nature, so the surface +that asked for it owns it (the browse panel keeps `relay`, `candidates` and +`loading` in its own state, and drops them with the panel). + +`BrowseSettled` is driven by the subscribe task itself, not by `EOSE`/`CLOSED` +notifications: it is sent once `subscribe(..).await` returns, which happens on +EOSE, on a refusal, or when the auto-close timeout fires. So the answer always +ends, even against a relay that never EOSEs. A candidate can still arrive after +the settle — the pump and the subscribe task are different producers — which is +why the relay stays mapped. Join a result with `join_key(candidate.key, None, cx)`. ## Creating a group @@ -496,14 +512,22 @@ impl GroupsRegistry { } ``` -- `GroupsRegistry` holds `groups: Vec>`, the `10009` `list`, the - live `browse` results, `routes` (subscription id → `GroupKey`), one - `Subscription` per group, the fork state, and its tasks. +- `GroupsRegistry` holds `groups: Vec>`, the `10009` `list`, + `routes` (subscription id → `GroupKey`), the relay behind each live `brw-` REQ, + one `Subscription` per group, the fork state, and its tasks. It holds no browse + result: a browse is fan-out, and the caller keeps the candidates. - `Group` owns everything the panel draws: metadata, admins/members/roles, participants, pins, membership, the `Vec` timeline, the `previous` window, `loading`/`loading_more`/`refused`. - Views render `Entity` and re-read on `GroupEvent`; no protocol state lives in a view. +- The registry re-broadcasts every `GroupEvent::Updated` as `GroupsEvent::Updated` + and notifies itself, so a view that watches only the registry (the sidebar, + which renders `groups()` as rows) stays in sync when a group's metadata, + membership, pins or fork state change. `GroupEvent::Updated` fires for state and + membership only, never for an arriving message — that is `GroupEvent::Incoming`, + and it is why a rebuild of the row list does not happen per message. A view that + renders the timeline watches the `Group` itself. ### Foreground and background @@ -545,20 +569,23 @@ before they reach the consumer, so a replay cannot double-hydrate. ## Not wired up yet -- **Browsing, details and administration are not built.** The sidebar's `Groups` - tab lists `GroupsRegistry::groups()`, opens a `GroupPanel`, and offers the - new-group and join dialogs, but `crates/workspace/src/panels/browse.rs` is still - a stub, and the group details, moderation and pin surfaces are still to be built. +- **Details and administration are not built.** The sidebar's `Groups` tab lists + `GroupsRegistry::groups()`, opens a `GroupPanel`, and offers the new-group and + join dialogs; `crates/workspace/src/panels/browse.rs` browses a relay and joins + from a result. The group details, moderation and pin surfaces are still to be + built. - **Remote deletions are not folded in.** A `9005` from another admin never reaches the standing REQ, so only our own accepted deletes evict the row. - **The LiveKit response shape is a guess.** The NIP names no fields, so `LivekitSession::parse` reads `token` (or `jwt`) and `url` (or `server_url`) and fails loudly on anything else. Re-check it against the relay actually targeted before building the AV UI, or the join button fails silently. -- **A relay that never EOSEs sticks a spinner.** `Browse::loading` and - `Group::loading_more` both rely on EOSE or CLOSED, and the auto-close timeout - closes the subscription with no notification at all. Bind "load more" to - `Group::loading_more()` and give the user a way out. +- **A relay that never EOSEs bounds how long "load more" spins.** + `Group::loading_more` relies on EOSE or CLOSED, and the auto-close timeout + closes the subscription with no notification at all; bind "load more" to + `Group::loading_more()` and give the user a way out. A browse is fine by + contrast: `BrowseSettled` comes from the subscribe task returning, so it + always lands. - **Subgroups are parsed, not assembled.** `parent`/`children` are on `GroupMetadata` and the writes carry them; there is no tree builder, because the tree is scoped to a relay and that is a view concern.