From f6c9632f40552b2f8177b856df6ea3685348ec7a Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 12 May 2026 13:19:42 -0400 Subject: [PATCH 1/2] fix(desktop): populate member_count in get_channels so channel browser shows real counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel browser dialog renders `channel.memberCount` directly, but `get_channels` was always returning 0 for that field. The value comes from a kind:40901 'channel summary' sidecar in `channel_info_from_event`, and every caller passed `None` for the summary — the relay doesn't emit kind:40901 yet either. The top bar's count appeared to work only because `ChannelMembersBar` does its own live kind:39002 fetch via `useChannelMembersQuery` and counts that, ignoring `channel.memberCount`. Fix at the Tauri layer: after assembling the channel list, issue a single batched kind:39002 query for every channel id and count unique p-tag pubkeys. One extra relay round-trip total, not N. If the query fails we fall back to 0 (prior behavior) rather than breaking the whole listing. When the kind:40901 summary sidecar is implemented relay-side this extra query can be removed in favor of the existing summary path. Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- desktop/src-tauri/src/commands/channels.rs | 56 ++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index edd87848..37e991eb 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -116,6 +116,62 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result channels.push(info); } } + + // Populate member_count by batch-fetching kind:39002 for every listed + // channel and counting unique p-tag pubkeys. The kind:40901 summary + // sidecar that channel_info_from_event prefers isn't emitted by the + // relay today, so without this step every channel reports 0 members + // in the channel browser (the active-channel top bar masks this with + // its own live members query). + let all_d_tags: Vec = channels.iter().map(|c| c.id.clone()).collect(); + if !all_d_tags.is_empty() { + let members_events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [39002], + "#d": all_d_tags, + "limit": all_d_tags.len(), + })], + ) + .await + .unwrap_or_default(); + + let mut counts: std::collections::HashMap = + std::collections::HashMap::with_capacity(members_events.len()); + for ev in &members_events { + let mut d_value: Option = None; + let mut unique_pubkeys: std::collections::HashSet = + std::collections::HashSet::new(); + for tag in ev.tags.iter() { + let slice = tag.as_slice(); + match slice.first().map(String::as_str) { + Some("d") if d_value.is_none() => { + if let Some(v) = slice.get(1) { + d_value = Some(v.clone()); + } + } + Some("p") => { + if let Some(pk) = slice.get(1) { + if !pk.is_empty() { + unique_pubkeys.insert(pk.clone()); + } + } + } + _ => {} + } + } + if let Some(d) = d_value { + counts.insert(d, unique_pubkeys.len() as i64); + } + } + + for channel in &mut channels { + if let Some(count) = counts.get(&channel.id) { + channel.member_count = *count; + } + } + } + Ok(channels) } From ebaae2f7e30f90e0eee2bc80ac8a4bdc1fdb4059 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 12 May 2026 15:22:21 -0400 Subject: [PATCH 2/2] refactor(desktop): reuse channel_members_from_event in get_channels + add tests Replace the inline kind:39002 dedupe/count loop with a small helper that delegates p-tag parsing to nostr_convert::channel_members_from_event, matching get_channel_members. One source of truth for member dedupe. Add five unit tests for count_members_by_channel covering: per-channel counts, duplicate-pubkey dedupe, missing d-tag (skipped), zero-member channel (recorded as 0 so it overwrites the default), and empty input. Tests live in a sibling channels_tests.rs to keep channels.rs under the per-file line cap. Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- desktop/src-tauri/src/commands/channels.rs | 56 ++++++------ .../src-tauri/src/commands/channels_tests.rs | 85 +++++++++++++++++++ 2 files changed, 112 insertions(+), 29 deletions(-) create mode 100644 desktop/src-tauri/src/commands/channels_tests.rs diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 37e991eb..722eb83d 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -136,35 +136,7 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result .await .unwrap_or_default(); - let mut counts: std::collections::HashMap = - std::collections::HashMap::with_capacity(members_events.len()); - for ev in &members_events { - let mut d_value: Option = None; - let mut unique_pubkeys: std::collections::HashSet = - std::collections::HashSet::new(); - for tag in ev.tags.iter() { - let slice = tag.as_slice(); - match slice.first().map(String::as_str) { - Some("d") if d_value.is_none() => { - if let Some(v) = slice.get(1) { - d_value = Some(v.clone()); - } - } - Some("p") => { - if let Some(pk) = slice.get(1) { - if !pk.is_empty() { - unique_pubkeys.insert(pk.clone()); - } - } - } - _ => {} - } - } - if let Some(d) = d_value { - counts.insert(d, unique_pubkeys.len() as i64); - } - } - + let counts = count_members_by_channel(&members_events); for channel in &mut channels { if let Some(count) = counts.get(&channel.id) { channel.member_count = *count; @@ -175,6 +147,28 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result Ok(channels) } +/// Build a `channel_id → unique-member-count` map from a batch of kind:39002 +/// events. Events without a `d` tag are skipped; member dedupe is delegated to +/// [`nostr_convert::channel_members_from_event`] so the parsing rules match the +/// per-channel `get_channel_members` path. +fn count_members_by_channel(events: &[nostr::Event]) -> std::collections::HashMap { + let mut counts: std::collections::HashMap = + std::collections::HashMap::with_capacity(events.len()); + for ev in events { + let Some(d) = ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + (s.len() >= 2 && s[0] == "d").then(|| s[1].clone()) + }) else { + continue; + }; + let Ok(resp) = nostr_convert::channel_members_from_event(ev) else { + continue; + }; + counts.insert(d, resp.members.len() as i64); + } + counts +} + #[tauri::command] pub async fn get_channel_details( channel_id: String, @@ -471,3 +465,7 @@ pub async fn leave_channel(channel_id: String, state: State<'_, AppState>) -> Re submit_event(builder, &state).await?; Ok(()) } + +#[cfg(test)] +#[path = "channels_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs new file mode 100644 index 00000000..2fb740fd --- /dev/null +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -0,0 +1,85 @@ +// Tests for commands/channels.rs — split into a sibling file to keep +// channels.rs under the per-file line cap. + +use super::*; +use nostr::{EventBuilder, Keys, Kind, Tag}; + +/// Build a signed event for testing with the given kind, content, and tags. +fn ev(kind: u16, content: &str, tags: Vec>) -> nostr::Event { + let keys = Keys::generate(); + let parsed: Vec = tags + .into_iter() + .map(|t| Tag::parse(t).expect("parse tag")) + .collect(); + EventBuilder::new(Kind::from_u16(kind), content) + .tags(parsed) + .sign_with_keys(&keys) + .expect("sign") +} + +// A 64-hex pubkey (nostr p-tags require 32-byte hex). +const PK_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const PK_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const PK_C: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + +#[test] +fn counts_unique_p_tags_per_channel() { + let e1 = ev( + 39002, + "", + vec![ + vec!["d", "chan-1"], + vec!["p", PK_A, "", "member"], + vec!["p", PK_B, "", "admin"], + ], + ); + let e2 = ev( + 39002, + "", + vec![vec!["d", "chan-2"], vec!["p", PK_C, "", "member"]], + ); + + let counts = count_members_by_channel(&[e1, e2]); + assert_eq!(counts.get("chan-1"), Some(&2)); + assert_eq!(counts.get("chan-2"), Some(&1)); + assert_eq!(counts.len(), 2); +} + +#[test] +fn dedupes_repeated_pubkeys() { + let e = ev( + 39002, + "", + vec![ + vec!["d", "chan-1"], + vec!["p", PK_A, "", "member"], + vec!["p", PK_A, "", "admin"], // duplicate pubkey, different role + vec!["p", PK_B, "", "member"], + ], + ); + let counts = count_members_by_channel(&[e]); + assert_eq!(counts.get("chan-1"), Some(&2)); +} + +#[test] +fn skips_event_without_d_tag() { + let e = ev(39002, "", vec![vec!["p", PK_A, "", "member"]]); + let counts = count_members_by_channel(&[e]); + assert!(counts.is_empty()); +} + +#[test] +fn zero_member_channel_is_recorded() { + // A channel with a members event but no p-tags should report 0, + // not be absent from the map (the caller relies on `get` returning + // `Some(0)` to overwrite a default). + let e = ev(39002, "", vec![vec!["d", "chan-1"]]); + let counts = count_members_by_channel(&[e]); + assert_eq!(counts.get("chan-1"), Some(&0)); +} + +#[test] +fn empty_input_yields_empty_map() { + let counts = count_members_by_channel(&[]); + assert!(counts.is_empty()); +}