feat: add group discovery and group preview #59

Merged
reya merged 10 commits from browse-group into master 2026-10-08 00:18:27 +00:00
2 changed files with 79 additions and 100 deletions
Showing only changes of commit 586fea796d - Show all commits
+61 -78
View File
@@ -90,7 +90,6 @@ fn is_own_subscription(id: &SubscriptionId) -> bool {
|| id.starts_with(PAGE_PREFIX) || id.starts_with(PAGE_PREFIX)
} }
/// The group id, member count and timestamp of a group members event.
fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize)> { fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize)> {
if event.kind != Kind::GroupMembers { if event.kind != Kind::GroupMembers {
return None; return None;
@@ -102,8 +101,6 @@ fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize)> {
Some((id, event.created_at, members)) Some((id, event.created_at, members))
} }
/// Reconcile a filter with the relays via negentropy, returning the events
/// that were downloaded to the local database.
async fn reconcile( async fn reconcile(
client: &Client, client: &Client,
filter: Filter, filter: Filter,
@@ -112,22 +109,19 @@ async fn reconcile(
let output = match client.sync(filter).with(urls).await { let output = match client.sync(filter).with(urls).await {
Ok(output) => output, Ok(output) => output,
Err(error) => { Err(error) => {
log::warn!("nip29: the reconciliation failed: {error}"); log::warn!("the reconciliation failed: {error}");
return HashMap::new(); return HashMap::new();
} }
}; };
for (relay, error) in &output.failed { for (relay, error) in &output.failed {
log::debug!("nip29: syncing with {relay} failed: {error}"); log::debug!("syncing with {relay} failed: {error}");
} }
output.value.received output.value.received
} }
/// Pair the downloaded events with the relay that served them. /// Pair the downloaded events with the relay that served them.
///
/// The database does not keep relay associations, so the pairing has to
/// happen while the reconciliation still knows them.
async fn pull_received( async fn pull_received(
client: &Client, client: &Client,
received: HashMap<EventId, HashSet<RelayUrl>>, received: HashMap<EventId, HashSet<RelayUrl>>,
@@ -202,16 +196,15 @@ impl GroupsRegistry {
})); }));
cx.defer(move |cx| { cx.defer(move |cx| {
if let Err(error) = this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.handle_notifications(cx); this.handle_notifications(cx);
this.load_cached(cx); this.load_cached(cx);
if nostr.read(cx).current_user().is_some() { if nostr.read(cx).current_user().is_some() {
this.load(cx); this.load(cx);
} }
}) { })
log::warn!("nip29: initializing the registry failed: {error}"); .ok();
}
}); });
Self { Self {
@@ -278,7 +271,7 @@ impl GroupsRegistry {
}); });
} }
}) { }) {
log::warn!("nip29: marking the refused join failed: {error}"); log::warn!("marking the refused join failed: {error}");
} }
} }
})); }));
@@ -337,11 +330,11 @@ impl GroupsRegistry {
.close_on(opts) .close_on(opts)
.await .await
{ {
log::warn!("nip29: browsing {relay} failed: {error}"); log::warn!("browsing {relay} failed: {error}");
} }
if let Err(error) = tx.send_async(Signal::BrowseSettled(id)).await { if let Err(error) = tx.send_async(Signal::BrowseSettled(id)).await {
log::warn!("nip29: the browse settle signal was dropped: {error}"); log::warn!("the browse settle signal was dropped: {error}");
} }
})); }));
} }
@@ -405,7 +398,7 @@ impl GroupsRegistry {
let (authors, lists) = match task.await { let (authors, lists) = match task.await {
Ok(ok) => ok, Ok(ok) => ok,
Err(error) => { Err(error) => {
log::warn!("nip29: loading the cached groups failed: {error}"); log::warn!("loading the cached groups failed: {error}");
(HashSet::new(), Vec::new()) (HashSet::new(), Vec::new())
} }
}; };
@@ -413,21 +406,20 @@ impl GroupsRegistry {
if let Err(error) = this.update(cx, |this, cx| { if let Err(error) = this.update(cx, |this, cx| {
this.absorb_lists(lists, cx); this.absorb_lists(lists, cx);
}) { }) {
log::warn!("nip29: applying the cached group lists failed: {error}"); log::warn!("applying the cached group lists failed: {error}");
return; return;
} }
// Show the cached metadata before the relays answer.
let previews = match this.update(cx, |this, cx| this.load_cached_previews(cx)) { let previews = match this.update(cx, |this, cx| this.load_cached_previews(cx)) {
Ok(task) => match task.await { Ok(task) => match task.await {
Ok(previews) => previews, Ok(previews) => previews,
Err(error) => { Err(error) => {
log::warn!("nip29: loading the cached group metadata failed: {error}"); log::warn!("loading the cached group metadata failed: {error}");
Vec::new() Vec::new()
} }
}, },
Err(error) => { Err(error) => {
log::warn!("nip29: discovering the groups failed: {error}"); log::warn!("discovering the groups failed: {error}");
return; return;
} }
}; };
@@ -437,12 +429,12 @@ impl GroupsRegistry {
Ok(task) => match task.await { Ok(task) => match task.await {
Ok(members) => members, Ok(members) => members,
Err(error) => { Err(error) => {
log::warn!("nip29: loading the cached group members failed: {error}"); log::warn!("loading the cached group members failed: {error}");
Vec::new() Vec::new()
} }
}, },
Err(error) => { Err(error) => {
log::warn!("nip29: discovering the groups failed: {error}"); log::warn!("discovering the groups failed: {error}");
return; return;
} }
}; };
@@ -451,16 +443,14 @@ impl GroupsRegistry {
this.absorb_cached_previews(previews, cx); this.absorb_cached_previews(previews, cx);
this.absorb_cached_members(members, cx); this.absorb_cached_members(members, cx);
}) { }) {
log::warn!("nip29: discovering the groups failed: {error}"); log::warn!("discovering the groups failed: {error}");
return; return;
} }
// Reconcile the contacts' group lists with the pool relays: only
// the events missing locally are downloaded and stored.
let lists = match this.update(cx, |this, cx| this.sync_lists(authors, cx)) { let lists = match this.update(cx, |this, cx| this.sync_lists(authors, cx)) {
Ok(task) => task, Ok(task) => task,
Err(error) => { Err(error) => {
log::warn!("nip29: discovering the groups failed: {error}"); log::warn!("discovering the groups failed: {error}");
return; return;
} }
}; };
@@ -469,7 +459,7 @@ impl GroupsRegistry {
let sweep = match this.update(cx, |this, cx| this.sync_sweep(cx)) { let sweep = match this.update(cx, |this, cx| this.sync_sweep(cx)) {
Ok(task) => task, Ok(task) => task,
Err(error) => { Err(error) => {
log::warn!("nip29: discovering the groups failed: {error}"); log::warn!("discovering the groups failed: {error}");
return; return;
} }
}; };
@@ -477,7 +467,7 @@ impl GroupsRegistry {
let lists = match lists.await { let lists = match lists.await {
Ok(lists) => lists, Ok(lists) => lists,
Err(error) => { Err(error) => {
log::warn!("nip29: syncing the contacts group lists failed: {error}"); log::warn!("syncing the contacts group lists failed: {error}");
Vec::new() Vec::new()
} }
}; };
@@ -485,13 +475,13 @@ impl GroupsRegistry {
if let Err(error) = this.update(cx, |this, cx| { if let Err(error) = this.update(cx, |this, cx| {
this.absorb_lists(lists, cx); this.absorb_lists(lists, cx);
}) { }) {
log::warn!("nip29: applying the contacts group lists failed: {error}"); log::warn!("applying the contacts group lists failed: {error}");
} }
let sweep = match sweep.await { let sweep = match sweep.await {
Ok(events) => events, Ok(events) => events,
Err(error) => { Err(error) => {
log::warn!("nip29: syncing the discovered groups failed: {error}"); log::warn!("syncing the discovered groups failed: {error}");
Vec::new() Vec::new()
} }
}; };
@@ -501,7 +491,7 @@ impl GroupsRegistry {
this.absorb_discovered(relay, &event, cx); this.absorb_discovered(relay, &event, cx);
} }
}) { }) {
log::warn!("nip29: applying the discovered groups failed: {error}"); log::warn!("applying the discovered groups failed: {error}");
} }
// The previews cover the relays the lists just referenced. // The previews cover the relays the lists just referenced.
@@ -509,12 +499,12 @@ impl GroupsRegistry {
Ok(task) => match task.await { Ok(task) => match task.await {
Ok(previews) => previews, Ok(previews) => previews,
Err(error) => { Err(error) => {
log::warn!("nip29: syncing the group previews failed: {error}"); log::warn!("syncing the group previews failed: {error}");
Vec::new() Vec::new()
} }
}, },
Err(error) => { Err(error) => {
log::warn!("nip29: discovering the groups failed: {error}"); log::warn!("discovering the groups failed: {error}");
return; return;
} }
}; };
@@ -527,7 +517,7 @@ impl GroupsRegistry {
this.discovery this.discovery
.update(cx, |discovery, cx| discovery.settle(cx)); .update(cx, |discovery, cx| discovery.settle(cx));
}) { }) {
log::warn!("nip29: discovering the groups failed: {error}"); log::warn!("discovering the groups failed: {error}");
} }
})); }));
} }
@@ -560,7 +550,7 @@ impl GroupsRegistry {
let metadata = match GroupMetadata::parse(&event) { let metadata = match GroupMetadata::parse(&event) {
Ok(metadata) => metadata, Ok(metadata) => metadata,
Err(error) => { Err(error) => {
log::warn!("nip29: ignoring malformed cached group metadata: {error}"); log::warn!("ignoring malformed cached group metadata: {error}");
return newest; return newest;
} }
}; };
@@ -717,7 +707,7 @@ impl GroupsRegistry {
let metadata = match GroupMetadata::parse(event) { let metadata = match GroupMetadata::parse(event) {
Ok(metadata) => metadata, Ok(metadata) => metadata,
Err(error) => { Err(error) => {
log::warn!("nip29: ignoring malformed group metadata from {relay}: {error}"); log::warn!("ignoring malformed group metadata from {relay}: {error}");
return; return;
} }
}; };
@@ -762,7 +752,7 @@ impl GroupsRegistry {
async move { async move {
if let Err(error) = client.add_relay(relay.clone()).and_connect().await { if let Err(error) = client.add_relay(relay.clone()).and_connect().await {
log::warn!("nip29: connecting to {relay} failed: {error}"); log::warn!("connecting to {relay} failed: {error}");
} }
let filter = Filter::new() let filter = Filter::new()
@@ -811,10 +801,7 @@ impl GroupsRegistry {
fn absorb_group_list(&mut self, event: &Event, cx: &mut Context<Self>) { fn absorb_group_list(&mut self, event: &Event, cx: &mut Context<Self>) {
let Ok(list) = GroupList::parse(event) else { let Ok(list) = GroupList::parse(event) else {
log::warn!( log::warn!("ignoring a malformed group list from {}", event.pubkey);
"nip29: ignoring a malformed group list from {}",
event.pubkey
);
return; return;
}; };
@@ -861,7 +848,7 @@ impl GroupsRegistry {
let metadata = match GroupMetadata::parse(event) { let metadata = match GroupMetadata::parse(event) {
Ok(metadata) => metadata, Ok(metadata) => metadata,
Err(error) => { Err(error) => {
log::warn!("nip29: ignoring malformed group metadata from {relay}: {error}"); log::warn!("ignoring malformed group metadata from {relay}: {error}");
return; return;
} }
}; };
@@ -925,7 +912,7 @@ impl GroupsRegistry {
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(error) = sent.await { if let Err(error) = sent.await {
log::warn!("nip29: leaving {} failed: {error}", key.id()); log::warn!("leaving {} failed: {error}", key.id());
return; return;
} }
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
@@ -963,7 +950,7 @@ impl GroupsRegistry {
self.tasks.push(cx.background_spawn(async move { self.tasks.push(cx.background_spawn(async move {
for id in ids { for id in ids {
if let Err(error) = client.unsubscribe(&id).await { if let Err(error) = client.unsubscribe(&id).await {
log::warn!("nip29: unsubscribing {id} failed: {error}"); log::warn!("unsubscribing {id} failed: {error}");
} }
} }
})); }));
@@ -995,11 +982,14 @@ impl GroupsRegistry {
self.tasks self.tasks
.push(cx.spawn(async move |this, cx| match task.await { .push(cx.spawn(async move |this, cx| match task.await {
Ok(list) => { Ok(list) => {
if let Err(error) = this.update(cx, |this, cx| this.merge(list, cx)) { this.update(cx, |this, cx| {
log::warn!("nip29: applying the cached group list failed: {error}"); this.merge(list, cx);
} })
.ok();
}
Err(error) => {
log::warn!("loading the cached group list failed: {error}");
} }
Err(error) => log::warn!("nip29: loading the cached group list failed: {error}"),
})); }));
} }
@@ -1008,7 +998,7 @@ impl GroupsRegistry {
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let updates: Task<Result<(), Error>> = cx.background_spawn(async move { cx.background_spawn(async move {
let me = signer.get_public_key_async().await?; let me = signer.get_public_key_async().await?;
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1); let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
@@ -1019,14 +1009,9 @@ impl GroupsRegistry {
.close_on(opts) .close_on(opts)
.await?; .await?;
Ok(()) Ok::<(), anyhow::Error>(())
}); })
.detach();
self.tasks.push(cx.spawn(async move |_this, _cx| {
if let Err(error) = updates.await {
log::warn!("nip29: subscribing to the group list failed: {error}");
}
}));
self.watch_forks(cx); self.watch_forks(cx);
} }
@@ -1050,7 +1035,6 @@ impl GroupsRegistry {
self.tasks.push(cx.background_spawn(async move { self.tasks.push(cx.background_spawn(async move {
loop { loop {
executor.timer(FORK_INTERVAL).await; executor.timer(FORK_INTERVAL).await;
if tx.send_async(Signal::Forks).await.is_err() { if tx.send_async(Signal::Forks).await.is_err() {
break; break;
} }
@@ -1091,14 +1075,14 @@ impl GroupsRegistry {
.with_id(fork_subscription_id()) .with_id(fork_subscription_id())
.await .await
{ {
log::warn!("nip29: subscribing to the admins' group lists failed: {error}"); log::warn!("subscribing to the admins' group lists failed: {error}");
} }
})); }));
} }
fn absorb_fork_list(&mut self, event: &Event, cx: &mut Context<Self>) { fn absorb_fork_list(&mut self, event: &Event, cx: &mut Context<Self>) {
let Ok(list) = GroupList::parse(event) else { let Ok(list) = GroupList::parse(event) else {
log::warn!("nip29: ignoring a malformed admin group list"); log::warn!("ignoring a malformed admin group list");
return; return;
}; };
@@ -1201,7 +1185,7 @@ impl GroupsRegistry {
self.tasks.push(cx.background_spawn(async move { self.tasks.push(cx.background_spawn(async move {
for id in [id, page] { for id in [id, page] {
if let Err(error) = client.unsubscribe(&id).await { if let Err(error) = client.unsubscribe(&id).await {
log::warn!("nip29: unsubscribing {id} failed: {error}"); log::warn!("unsubscribing {id} failed: {error}");
} }
} }
@@ -1210,7 +1194,7 @@ impl GroupsRegistry {
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag); .custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
if let Err(error) = client.database().delete(filter).await { if let Err(error) = client.database().delete(filter).await {
log::warn!("nip29: clearing the group cache failed: {error}"); log::warn!("clearing the group cache failed: {error}");
} }
})); }));
@@ -1271,18 +1255,17 @@ impl GroupsRegistry {
let event = match builder.finalize_async(&signer).await { let event = match builder.finalize_async(&signer).await {
Ok(event) => event, Ok(event) => event,
Err(error) => { Err(error) => {
log::warn!("nip29: signing the group list failed: {error}"); log::warn!("signing the group list failed: {error}");
return; return;
} }
}; };
match client.send_event(&event).to_nip65().await { match client.send_event(&event).to_nip65().await {
Ok(output) if output.failed.is_empty() => {} Ok(output) if output.failed.is_empty() => {}
Ok(output) => log::warn!( Ok(output) => {
"nip29: {} relay(s) rejected the group list", log::warn!("{} relay(s) rejected the group list", output.failed.len())
output.failed.len() }
), Err(error) => log::warn!("publishing the group list failed: {error}"),
Err(error) => log::warn!("nip29: publishing the group list failed: {error}"),
} }
})); }));
} }
@@ -1307,7 +1290,7 @@ impl GroupsRegistry {
.read_with(cx, |group, cx| group.save_envelope(event, cx)) .read_with(cx, |group, cx| group.save_envelope(event, cx))
.await .await
{ {
log::warn!("nip29: caching failed: {error}"); log::warn!("caching failed: {error}");
return; return;
} }
@@ -1318,7 +1301,7 @@ impl GroupsRegistry {
}); });
} }
Err(error) => { Err(error) => {
log::warn!("nip29: reloading {} failed: {error}", logged.id()); log::warn!("reloading {} failed: {error}", logged.id());
} }
} }
})); }));
@@ -1345,7 +1328,7 @@ impl GroupsRegistry {
}); });
} }
Err(error) => { Err(error) => {
log::warn!("nip29: loading {} state failed: {error}", logged.id()); log::warn!("loading {} state failed: {error}", logged.id());
} }
})); }));
} }
@@ -1365,7 +1348,7 @@ impl GroupsRegistry {
.read_with(cx, |group, cx| group.save_envelope(event.clone(), cx)) .read_with(cx, |group, cx| group.save_envelope(event.clone(), cx))
.await .await
{ {
log::warn!("nip29: caching failed: {error}"); log::warn!("caching failed: {error}");
return; return;
} }
@@ -1411,7 +1394,7 @@ impl GroupsRegistry {
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(error) = purge.await { if let Err(error) = purge.await {
log::warn!("nip29: dropping the deleted envelope failed: {error}"); log::warn!("dropping the deleted envelope failed: {error}");
} }
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
group.update(cx, |group, cx| { group.update(cx, |group, cx| {
@@ -1427,7 +1410,7 @@ impl GroupsRegistry {
if id == list_subscription_id() { if id == list_subscription_id() {
match GroupList::parse(&event) { match GroupList::parse(&event) {
Ok(list) => self.merge(list, cx), Ok(list) => self.merge(list, cx),
Err(error) => log::warn!("nip29: ignoring a malformed group list: {error}"), Err(error) => log::warn!("ignoring a malformed group list: {error}"),
} }
return; return;
} }
@@ -1470,7 +1453,7 @@ impl GroupsRegistry {
} }
let Ok(metadata) = GroupMetadata::parse(event) else { let Ok(metadata) = GroupMetadata::parse(event) else {
log::warn!("nip29: ignoring malformed group metadata from {relay}"); log::warn!("ignoring malformed group metadata from {relay}");
return; return;
}; };
@@ -1515,12 +1498,12 @@ 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(relay) = self.browse.get(&id) { if let Some(relay) = self.browse.get(&id) {
log::warn!("nip29: browsing {relay} was refused: {reason}"); log::warn!("browsing {relay} was refused: {reason}");
return; return;
} }
if id == fork_subscription_id() { if id == fork_subscription_id() {
log::debug!("nip29: the admins' group lists were refused: {reason}"); log::debug!("the admins' group lists were refused: {reason}");
return; return;
} }
@@ -1531,7 +1514,7 @@ impl GroupsRegistry {
if is_page(&id) { if is_page(&id) {
self.routes.remove(&id); self.routes.remove(&id);
log::warn!( log::warn!(
"nip29: loading older messages in {} was refused: {reason}", "loading older messages in {} was refused: {reason}",
key.id() key.id()
); );
+18 -22
View File
@@ -16,7 +16,6 @@ use theme::ActiveTheme;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use ui::indicator::Indicator;
use ui::input::{Input, InputEvent, InputState}; use ui::input::{Input, InputEvent, InputState};
use ui::notification::Notification; use ui::notification::Notification;
use ui::scroll::Scrollbar; use ui::scroll::Scrollbar;
@@ -214,10 +213,6 @@ impl BrowsePanel {
self.render_card_rows(cards, cx) self.render_card_rows(cards, cx)
} }
/// A row holds two group cards side by side.
///
/// The cards are the relative slice of the visible rows, so they pair up
/// in order, two per row.
fn render_card_rows( fn render_card_rows(
&self, &self,
cards: &[GroupCandidate], cards: &[GroupCandidate],
@@ -235,8 +230,6 @@ impl BrowsePanel {
.p_2() .p_2()
.gap_2() .gap_2()
.children(first.map(|card| self.render_card(card, cx))) .children(first.map(|card| self.render_card(card, cx)))
// Keep an odd last card in one column: an empty spacer
// takes the second slot so the card cannot stretch.
.child(match second { .child(match second {
Some(card) => self.render_card(card, cx), Some(card) => self.render_card(card, cx),
None => div().flex_1().into_any_element(), None => div().flex_1().into_any_element(),
@@ -339,7 +332,7 @@ impl BrowsePanel {
.text_sm() .text_sm()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.text_ellipsis() .text_ellipsis()
.line_clamp(3) .line_clamp(2)
.map(|this| { .map(|this| {
if let Some(about) = about { if let Some(about) = about {
this.child(about) this.child(about)
@@ -458,28 +451,31 @@ impl Render for BrowsePanel {
v_flex() v_flex()
.size_full() .size_full()
.child( .child(
h_flex() v_flex()
.gap_2() .h_32()
.px_2()
.flex_shrink_0() .flex_shrink_0()
.child(Input::new(&self.input).cleanable(true).flex_1()) .items_center()
.justify_center()
.bg(cx.theme().surface_background)
.child( .child(
Button::new("browse-go") h_flex()
.label("Go") .w_full()
.primary() .max_w_56()
.on_click(cx.listener(|this, _event, window, cx| { .gap_1()
this.browse(window, cx); .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| {
.when(self.loading || (discover && discovering), |this| { this.browse(window, cx);
this.child(Indicator::new().small().color(cx.theme().icon_accent)) }),
}), )),
),
) )
.child( .child(
div() div()
.flex_1() .flex_1()
.min_h_0() .min_h_0()
.relative() .relative()
.px_2()
.child(body) .child(body)
.child(Scrollbar::vertical(&self.scroll_handle)), .child(Scrollbar::vertical(&self.scroll_handle)),
) )