fix(desktop): populate member_count in get_channels so channel browser shows real counts (#548)

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
tlongwell-block
2026-05-14 20:38:01 -04:00
committed by GitHub
parent 3a3501c77b
commit 5cdb3062ed
2 changed files with 139 additions and 0 deletions
@@ -116,9 +116,59 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>
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<String> = 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<String, i64> {
let mut counts: std::collections::HashMap<String, i64> =
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;
@@ -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<Vec<&str>>) -> nostr::Event {
let keys = Keys::generate();
let parsed: Vec<Tag> = 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());
}