add browse panel for discovering relay groups
This commit is contained in:
+33
-57
@@ -35,17 +35,12 @@ struct GlobalGroupsRegistry(Entity<GroupsRegistry>);
|
|||||||
|
|
||||||
impl Global for GlobalGroupsRegistry {}
|
impl Global for GlobalGroupsRegistry {}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum GroupsEvent {
|
pub enum GroupsEvent {
|
||||||
Updated,
|
Updated,
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
Browsed(Box<GroupCandidate>),
|
||||||
|
BrowseSettled(RelayUrl),
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Browse {
|
|
||||||
pub relay: RelayUrl,
|
|
||||||
pub loading: bool,
|
|
||||||
pub groups: Vec<GroupCandidate>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -53,6 +48,7 @@ enum Signal {
|
|||||||
Event(SubscriptionId, Event),
|
Event(SubscriptionId, Event),
|
||||||
Eose(SubscriptionId),
|
Eose(SubscriptionId),
|
||||||
Closed(SubscriptionId, String),
|
Closed(SubscriptionId, String),
|
||||||
|
BrowseSettled(SubscriptionId),
|
||||||
Forks,
|
Forks,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +93,7 @@ impl EventEmitter<GroupsEvent> for GroupsRegistry {}
|
|||||||
pub struct GroupsRegistry {
|
pub struct GroupsRegistry {
|
||||||
groups: Vec<Entity<Group>>,
|
groups: Vec<Entity<Group>>,
|
||||||
list: GroupList,
|
list: GroupList,
|
||||||
browse: HashMap<SubscriptionId, Browse>,
|
browse: HashMap<SubscriptionId, RelayUrl>,
|
||||||
routes: HashMap<SubscriptionId, GroupKey>,
|
routes: HashMap<SubscriptionId, GroupKey>,
|
||||||
observers: HashMap<GroupKey, Subscription>,
|
observers: HashMap<GroupKey, Subscription>,
|
||||||
forks: HashMap<PublicKey, (Timestamp, GroupList)>,
|
forks: HashMap<PublicKey, (Timestamp, GroupList)>,
|
||||||
@@ -246,19 +242,11 @@ impl GroupsRegistry {
|
|||||||
pub fn browse(&mut self, relay: RelayUrl, cx: &mut Context<Self>) {
|
pub fn browse(&mut self, relay: RelayUrl, cx: &mut Context<Self>) {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
|
|
||||||
let id = browse_subscription(&relay);
|
let id = browse_subscription(&relay);
|
||||||
|
let tx = self.signal_tx.clone();
|
||||||
|
|
||||||
self.browse.insert(
|
self.browse.insert(id.clone(), relay.clone());
|
||||||
id.clone(),
|
|
||||||
Browse {
|
|
||||||
relay: relay.clone(),
|
|
||||||
loading: true,
|
|
||||||
groups: Vec::new(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
cx.emit(GroupsEvent::Updated);
|
|
||||||
cx.notify();
|
|
||||||
|
|
||||||
self.tasks.push(cx.background_spawn(async move {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
client.add_relay(relay.clone()).and_connect().await.ok();
|
client.add_relay(relay.clone()).and_connect().await.ok();
|
||||||
@@ -269,18 +257,25 @@ impl GroupsRegistry {
|
|||||||
.timeout(Some(BROWSE_TIMEOUT));
|
.timeout(Some(BROWSE_TIMEOUT));
|
||||||
|
|
||||||
if let Err(error) = client
|
if let Err(error) = client
|
||||||
.subscribe(ReqTarget::single(relay, [filter]))
|
.subscribe(ReqTarget::single(relay.clone(), [filter]))
|
||||||
.with_id(id)
|
.with_id(id.clone())
|
||||||
.close_on(opts)
|
.close_on(opts)
|
||||||
.await
|
.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> {
|
fn settle_browse(&mut self, id: SubscriptionId, cx: &mut Context<Self>) {
|
||||||
self.browse.get(&browse_subscription(relay))
|
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<Self>) {
|
pub fn load_more(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
||||||
@@ -516,6 +511,9 @@ impl GroupsRegistry {
|
|||||||
GroupEvent::Updated => {
|
GroupEvent::Updated => {
|
||||||
this.refresh_entry(group, cx);
|
this.refresh_entry(group, cx);
|
||||||
this.sync_forks(false, 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.upsert_entry(&key, Some(name));
|
||||||
self.publish_list(cx);
|
self.publish_list(cx);
|
||||||
|
|
||||||
cx.emit(GroupsEvent::Updated);
|
|
||||||
cx.notify();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn publish_list(&mut self, cx: &mut Context<Self>) {
|
fn publish_list(&mut self, cx: &mut Context<Self>) {
|
||||||
@@ -741,15 +736,12 @@ impl GroupsRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn absorb_candidate(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context<Self>) {
|
fn absorb_candidate(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context<Self>) {
|
||||||
let Some(browse) = self.browse.get_mut(id) else {
|
let Some(relay) = self.browse.get(id).cloned() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Ok(metadata) = GroupMetadata::parse(event) else {
|
let Ok(metadata) = GroupMetadata::parse(event) else {
|
||||||
log::warn!(
|
log::warn!("nip29: ignoring malformed group metadata from {relay}");
|
||||||
"nip29: ignoring malformed group metadata from {}",
|
|
||||||
browse.relay
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -757,28 +749,13 @@ impl GroupsRegistry {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let candidate = GroupCandidate {
|
cx.emit(GroupsEvent::Browsed(Box::new(GroupCandidate {
|
||||||
key: GroupKey::new(browse.relay.clone(), group),
|
key: GroupKey::new(relay, group),
|
||||||
metadata,
|
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<Self>) {
|
fn handle_eose(&mut self, id: SubscriptionId, cx: &mut Context<Self>) {
|
||||||
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 {
|
let Some(key) = self.routes.get(&id).cloned() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -807,12 +784,8 @@ impl GroupsRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context<Self>) {
|
fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context<Self>) {
|
||||||
if let Some(browse) = self.browse.get_mut(&id) {
|
if let Some(relay) = self.browse.get(&id) {
|
||||||
log::warn!("nip29: browsing {} was refused: {reason}", browse.relay);
|
log::warn!("nip29: browsing {relay} was refused: {reason}");
|
||||||
browse.loading = false;
|
|
||||||
|
|
||||||
cx.emit(GroupsEvent::Updated);
|
|
||||||
cx.notify();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -922,6 +895,9 @@ impl GroupsRegistry {
|
|||||||
Signal::Closed(id, reason) => {
|
Signal::Closed(id, reason) => {
|
||||||
this.handle_closed(id, reason, cx);
|
this.handle_closed(id, reason, cx);
|
||||||
}
|
}
|
||||||
|
Signal::BrowseSettled(id) => {
|
||||||
|
this.settle_browse(id, cx);
|
||||||
|
}
|
||||||
Signal::Forks => {
|
Signal::Forks => {
|
||||||
this.sync_forks(true, cx);
|
this.sync_forks(true, cx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -469,6 +469,18 @@ pub struct GroupCandidate {
|
|||||||
pub metadata: GroupMetadata,
|
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)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct GroupAdmins(Vec<(PublicKey, Vec<String>)>);
|
pub struct GroupAdmins(Vec<(PublicKey, Vec<String>)>);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use gpui::{App, AppContext, ParentElement, Window, px};
|
use gpui::{App, AppContext, ParentElement, Styled, Window, px};
|
||||||
use nip29::{GroupReference, GroupsRegistry};
|
use nip29::{GroupReference, GroupsRegistry};
|
||||||
use ui::WindowExtension;
|
use ui::WindowExtension;
|
||||||
use ui::input::{Input, InputState};
|
use ui::input::{Input, InputState};
|
||||||
@@ -12,9 +12,11 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
|||||||
|
|
||||||
this.width(px(380.))
|
this.width(px(380.))
|
||||||
.confirm()
|
.confirm()
|
||||||
.title("Join group")
|
.title("Join Group")
|
||||||
.child(Input::new(&input))
|
.child(Input::new(&input).cleanable(true).flex_1().text_sm())
|
||||||
.on_ok(move |_event, window, cx| {
|
.on_ok(move |_event, window, cx| {
|
||||||
|
let groups = GroupsRegistry::global(cx);
|
||||||
|
|
||||||
let reference = match GroupReference::parse(input.read(cx).value().trim()) {
|
let reference = match GroupReference::parse(input.read(cx).value().trim()) {
|
||||||
Ok(reference) => reference,
|
Ok(reference) => reference,
|
||||||
Err(error) => {
|
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);
|
registry.join(reference, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -283,7 +283,8 @@ impl Workspace {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
Command::ShowBrowse => {
|
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 => {
|
Command::ShowSearch => {
|
||||||
let panel = search::init(self.dock.downgrade(), window, cx);
|
let panel = search::init(self.dock.downgrade(), window, cx);
|
||||||
|
|||||||
@@ -1,32 +1,249 @@
|
|||||||
|
use std::ops::Range;
|
||||||
|
|
||||||
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
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 theme::ActiveTheme;
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::avatar::Avatar;
|
||||||
use ui::{Icon, IconName, Sizable, h_flex};
|
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<BrowsePanel> {
|
const TITLE: &str = "Browse";
|
||||||
cx.new(|cx| BrowsePanel::new(window, cx))
|
const INPUT_PLACEHOLDER: &str = "wss://relay.example.com";
|
||||||
|
|
||||||
|
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 {
|
pub struct BrowsePanel {
|
||||||
name: SharedString,
|
|
||||||
focus_handle: FocusHandle,
|
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>,
|
||||||
|
/// Whether the relay is still answering
|
||||||
|
loading: bool,
|
||||||
|
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BrowsePanel {
|
impl BrowsePanel {
|
||||||
fn new(_window: &mut Window, cx: &mut App) -> Self {
|
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(®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 {
|
Self {
|
||||||
name: "Browse".into(),
|
|
||||||
focus_handle: cx.focus_handle(),
|
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<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;
|
||||||
|
|
||||||
|
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(&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<usize>, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
||||||
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
|
let panel = cx.entity().downgrade();
|
||||||
|
|
||||||
|
let registry = GroupsRegistry::global(cx);
|
||||||
|
let joined: Vec<GroupKey> = 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 {
|
impl Panel for BrowsePanel {
|
||||||
fn panel_id(&self) -> SharedString {
|
fn panel_id(&self) -> SharedString {
|
||||||
self.name.clone()
|
TITLE.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn title(&self, cx: &App) -> AnyElement {
|
fn title(&self, cx: &App) -> AnyElement {
|
||||||
@@ -37,7 +254,7 @@ impl Panel for BrowsePanel {
|
|||||||
.small()
|
.small()
|
||||||
.text_color(cx.theme().icon_muted),
|
.text_color(cx.theme().icon_muted),
|
||||||
)
|
)
|
||||||
.child(self.name.clone())
|
.child(TITLE)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,11 +269,44 @@ impl Focusable for BrowsePanel {
|
|||||||
|
|
||||||
impl Render for BrowsePanel {
|
impl Render for BrowsePanel {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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()
|
.size_full()
|
||||||
.justify_center()
|
.child(
|
||||||
.text_sm()
|
h_flex()
|
||||||
.text_color(cx.theme().text_muted)
|
.gap_2()
|
||||||
.child(self.name.clone())
|
.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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,9 +98,16 @@ impl Sidebar {
|
|||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
|
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
|
||||||
if let GroupsEvent::Error(error) = event {
|
match event {
|
||||||
window.push_notification(Notification::error(error.clone()), cx);
|
GroupsEvent::Updated => {
|
||||||
}
|
// TODO: find a better way?
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
GroupsEvent::Error(error) => {
|
||||||
|
window.push_notification(Notification::error(error.clone()), cx);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+55
-28
@@ -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.
|
are in that list alongside members, and their `Group::membership()` says which.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum GroupsEvent {
|
pub enum GroupsEvent {
|
||||||
Updated,
|
Updated,
|
||||||
Error(String),
|
Error(String),
|
||||||
|
Browsed(Box<GroupCandidate>),
|
||||||
|
BrowseSettled(RelayUrl),
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`Updated` fires on any change to `groups()` or to a browse result; `Error` is only
|
`Updated` fires on any change to `groups()`; `Error` is only emitted when
|
||||||
emitted when publishing a `9007` (create) is refused, and carries the relay's
|
publishing a `9007` (create) is refused, and carries the relay's message.
|
||||||
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
|
## Value types
|
||||||
|
|
||||||
@@ -435,23 +439,35 @@ check runs with the relay gone, just at the next tick.
|
|||||||
## Browsing a relay
|
## Browsing a relay
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use nip29::Browse;
|
|
||||||
|
|
||||||
registry.update(cx, |registry, cx| registry.browse(relay_url.clone(), cx));
|
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<GroupCandidate> }`, where a candidate
|
A `GroupCandidate` is a `GroupKey` plus its parsed `GroupMetadata`; `display_name`
|
||||||
is a `GroupKey` plus its parsed `GroupMetadata`. `browse` connects the relay,
|
falls back to the id and `display_image` is the picture, the same pair `Group`
|
||||||
sends one auto-closing `39000` REQ (limit 500, EOSE or 10s) and returns
|
offers. `browse` connects the relay, sends one auto-closing `39000` REQ (limit
|
||||||
immediately; the pump folds each relay-signed metadata event into the result. Read
|
500, EOSE or 10s) and returns immediately; the pump parses each relay-signed
|
||||||
it back in a `GroupsEvent::Updated` handler, which also fires when the browse
|
metadata event and re-emits it as `Browsed`.
|
||||||
settles (`loading` flips false on EOSE or a refusal). Join a result with
|
|
||||||
`join_key(candidate.key, None, cx)`.
|
|
||||||
|
|
||||||
One caveat: `Browse::loading` can stick if the relay accepts the REQ and never
|
**The registry stores no browse result.** It keeps only which relay each `brw-`
|
||||||
EOSEs, because the auto-close timeout closes the subscription locally with no
|
REQ belongs to — the same routing bookkeeping as `routes`, and the reason
|
||||||
notification. Treat it as "still trying", not as an error.
|
`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
|
## Creating a group
|
||||||
|
|
||||||
@@ -496,14 +512,22 @@ impl GroupsRegistry {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `GroupsRegistry` holds `groups: Vec<Entity<Group>>`, the `10009` `list`, the
|
- `GroupsRegistry` holds `groups: Vec<Entity<Group>>`, the `10009` `list`,
|
||||||
live `browse` results, `routes` (subscription id → `GroupKey`), one
|
`routes` (subscription id → `GroupKey`), the relay behind each live `brw-` REQ,
|
||||||
`Subscription` per group, the fork state, and its tasks.
|
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,
|
- `Group` owns everything the panel draws: metadata, admins/members/roles,
|
||||||
participants, pins, membership, the `Vec<Message>` timeline, the `previous`
|
participants, pins, membership, the `Vec<Message>` timeline, the `previous`
|
||||||
window, `loading`/`loading_more`/`refused`.
|
window, `loading`/`loading_more`/`refused`.
|
||||||
- Views render `Entity<Group>` and re-read on `GroupEvent`; no protocol state
|
- Views render `Entity<Group>` and re-read on `GroupEvent`; no protocol state
|
||||||
lives in a view.
|
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
|
### Foreground and background
|
||||||
|
|
||||||
@@ -545,20 +569,23 @@ before they reach the consumer, so a replay cannot double-hydrate.
|
|||||||
|
|
||||||
## Not wired up yet
|
## Not wired up yet
|
||||||
|
|
||||||
- **Browsing, details and administration are not built.** The sidebar's `Groups`
|
- **Details and administration are not built.** The sidebar's `Groups` tab lists
|
||||||
tab lists `GroupsRegistry::groups()`, opens a `GroupPanel`, and offers the
|
`GroupsRegistry::groups()`, opens a `GroupPanel`, and offers the new-group and
|
||||||
new-group and join dialogs, but `crates/workspace/src/panels/browse.rs` is still
|
join dialogs; `crates/workspace/src/panels/browse.rs` browses a relay and joins
|
||||||
a stub, and the group details, moderation and pin surfaces are still to be built.
|
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
|
- **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.
|
standing REQ, so only our own accepted deletes evict the row.
|
||||||
- **The LiveKit response shape is a guess.** The NIP names no fields, so
|
- **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
|
`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
|
fails loudly on anything else. Re-check it against the relay actually targeted
|
||||||
before building the AV UI, or the join button fails silently.
|
before building the AV UI, or the join button fails silently.
|
||||||
- **A relay that never EOSEs sticks a spinner.** `Browse::loading` and
|
- **A relay that never EOSEs bounds how long "load more" spins.**
|
||||||
`Group::loading_more` both rely on EOSE or CLOSED, and the auto-close timeout
|
`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
|
closes the subscription with no notification at all; bind "load more" to
|
||||||
`Group::loading_more()` and give the user a way out.
|
`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
|
- **Subgroups are parsed, not assembled.** `parent`/`children` are on
|
||||||
`GroupMetadata` and the writes carry them; there is no tree builder, because
|
`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.
|
the tree is scoped to a relay and that is a view concern.
|
||||||
|
|||||||
Reference in New Issue
Block a user