diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index edd87848..722eb83d 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -116,9 +116,59 @@ 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 counts = count_members_by_channel(&members_events); + for channel in &mut channels { + if let Some(count) = counts.get(&channel.id) { + channel.member_count = *count; + } + } + } + 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, @@ -415,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()); +}