diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index dace77c6..3775d64d 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -88,6 +88,7 @@ pub struct Group { oldest: Option, loading: bool, loading_more: bool, + active: bool, refused: Option, tasks: SmallVec<[Task<()>; 2]>, } @@ -115,6 +116,7 @@ impl Group { oldest: None, loading: true, loading_more: false, + active: false, refused: None, tasks: smallvec![], } @@ -196,6 +198,14 @@ impl Group { self.loading_more } + pub(crate) fn is_active(&self) -> bool { + self.active + } + + pub(crate) fn activate(&mut self) { + self.active = true; + } + pub fn oldest(&self) -> Option { self.oldest } @@ -496,16 +506,25 @@ impl Group { }) } - pub(crate) fn load_envelopes(&self, cx: &App) -> Task>> { + pub(crate) fn load_envelopes(&self, cx: &App, state_only: bool) -> Task>> { let cache_tag = self.key.cache_tag(); let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); cx.background_spawn(async move { - let filter = Filter::new() + let mut filter = Filter::new() .kind(Kind::ApplicationSpecificData) .custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag); + if state_only { + let kinds = STATE_KINDS + .iter() + .chain(MEMBERSHIP_KINDS.iter()) + .map(|kind| kind.to_string()); + + filter = filter.custom_tags(SingleLetterTag::LOWERCASE_K, kinds); + } + let mut events: Vec = client .database() .query(filter) diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index cb6df300..7fb8b75e 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -549,7 +549,7 @@ impl GroupsRegistry { ); self.subscribe(key.clone(), cx); - self.reload(key, None, cx); + self.load_state(key, cx); } fn subscribe(&mut self, key: GroupKey, cx: &mut Context) { @@ -679,7 +679,7 @@ impl GroupsRegistry { }; let logged = key.clone(); - let load = group.read(cx).load_envelopes(cx); + let load = group.read(cx).load_envelopes(cx, false); self.tasks.push(cx.spawn(async move |_this, cx| { if let Some(event) = event @@ -704,6 +704,71 @@ impl GroupsRegistry { })); } + /// Load only the group state (metadata, members, membership) from the cache. + fn load_state(&mut self, key: GroupKey, cx: &mut Context) { + let Some(group) = self.group(&key, cx) else { + return; + }; + + let Some(me) = NostrRegistry::global(cx).read(cx).current_user() else { + return; + }; + + let logged = key.clone(); + let load = group.read(cx).load_envelopes(cx, true); + + self.tasks + .push(cx.spawn(async move |_this, cx| match load.await { + Ok(events) => { + group.update(cx, |group, cx| { + group.hydrate(events, me, cx); + }); + } + Err(error) => { + log::warn!("nip29: loading {} state failed: {error}", logged.id()); + } + })); + } + + /// Cache and absorb a single event without rebuilding the group. + fn absorb(&mut self, key: GroupKey, event: Event, cx: &mut Context) { + let Some(group) = self.group(&key, cx) else { + return; + }; + + let Some(me) = NostrRegistry::global(cx).read(cx).current_user() else { + return; + }; + + self.tasks.push(cx.spawn(async move |_this, cx| { + if let Err(error) = group + .read_with(cx, |group, cx| group.save_envelope(event.clone(), cx)) + .await + { + log::warn!("nip29: caching failed: {error}"); + return; + } + + group.update(cx, |group, cx| { + group.hydrate(vec![event], me, cx); + }); + })); + } + + /// Load the full group content on first open. + pub fn activate(&mut self, key: &GroupKey, cx: &mut Context) { + let Some(group) = self.group(key, cx) else { + return; + }; + + if group.read(cx).is_active() { + return; + } + + group.update(cx, |group, _cx| group.activate()); + self.reload(key.clone(), None, cx); + } + fn forget_event(&mut self, group: Entity, event: Event, cx: &mut Context) { let Some(target) = event.tags.event_ids().next() else { return; @@ -761,7 +826,15 @@ impl GroupsRegistry { return; }; - self.reload(key, Some(event), cx); + let active = self + .group(&key, cx) + .is_some_and(|group| group.read(cx).is_active()); + + if active { + self.reload(key, Some(event), cx); + } else { + self.absorb(key, event, cx); + } } fn absorb_candidate(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context) { @@ -819,7 +892,7 @@ impl GroupsRegistry { } if id == fork_subscription_id() { - log::warn!("nip29: the admins' group lists were refused: {reason}"); + log::debug!("nip29: the admins' group lists were refused: {reason}"); return; } diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 36d0411c..f020eef4 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -135,6 +135,13 @@ fn opens_run(rows: &[Row], index: usize) -> bool { } pub fn init(group: Entity, window: &mut Window, cx: &mut App) -> Entity { + let key = group.read(cx).key().clone(); + let groups = GroupsRegistry::global(cx); + + groups.update(cx, |registry, cx| { + registry.activate(&key, cx); + }); + cx.new(|cx| GroupPanel::new(group, window, cx)) } diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 00000000..435b3697 --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,19 @@ +# TODO + +Open items from the search and lazy-loading work. Each entry notes the known +tradeoff and where the fix would land. + +## nip29 + +- [ ] **Incremental event handling for open groups.** Live events for an open + (active) group still trigger a full envelope reload and re-hydrate of the + whole group on every event (`GroupsRegistry::reload` in + `crates/nip29/src/lib.rs`). Closed groups already absorb events in O(1); + open groups could use the same incremental path, keeping the full reload + only as an occasional self-healing rebuild. +- [ ] **Fetch admins' group lists from NIP-29 relays.** The fork-detection + query (`GroupsRegistry::sync_forks`) is unscoped, so group relays refuse it + by policy (`must have 'h', 'e' or 'a' tag`). It still works via generic and + gossip relays, but the deeper fix is a per-relay scoped subscription using + an `a` tag with `10009:` coordinates. The exact coordinate format + each relay accepts needs to be tested first.