Skip to content

Commit 5cdb306

Browse files
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>
1 parent 3a3501c commit 5cdb306

2 files changed

Lines changed: 139 additions & 0 deletions

File tree

desktop/src-tauri/src/commands/channels.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,59 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result<Vec<ChannelInfo>
116116
channels.push(info);
117117
}
118118
}
119+
120+
// Populate member_count by batch-fetching kind:39002 for every listed
121+
// channel and counting unique p-tag pubkeys. The kind:40901 summary
122+
// sidecar that channel_info_from_event prefers isn't emitted by the
123+
// relay today, so without this step every channel reports 0 members
124+
// in the channel browser (the active-channel top bar masks this with
125+
// its own live members query).
126+
let all_d_tags: Vec<String> = channels.iter().map(|c| c.id.clone()).collect();
127+
if !all_d_tags.is_empty() {
128+
let members_events = query_relay(
129+
&state,
130+
&[serde_json::json!({
131+
"kinds": [39002],
132+
"#d": all_d_tags,
133+
"limit": all_d_tags.len(),
134+
})],
135+
)
136+
.await
137+
.unwrap_or_default();
138+
139+
let counts = count_members_by_channel(&members_events);
140+
for channel in &mut channels {
141+
if let Some(count) = counts.get(&channel.id) {
142+
channel.member_count = *count;
143+
}
144+
}
145+
}
146+
119147
Ok(channels)
120148
}
121149

150+
/// Build a `channel_id → unique-member-count` map from a batch of kind:39002
151+
/// events. Events without a `d` tag are skipped; member dedupe is delegated to
152+
/// [`nostr_convert::channel_members_from_event`] so the parsing rules match the
153+
/// per-channel `get_channel_members` path.
154+
fn count_members_by_channel(events: &[nostr::Event]) -> std::collections::HashMap<String, i64> {
155+
let mut counts: std::collections::HashMap<String, i64> =
156+
std::collections::HashMap::with_capacity(events.len());
157+
for ev in events {
158+
let Some(d) = ev.tags.iter().find_map(|t| {
159+
let s = t.as_slice();
160+
(s.len() >= 2 && s[0] == "d").then(|| s[1].clone())
161+
}) else {
162+
continue;
163+
};
164+
let Ok(resp) = nostr_convert::channel_members_from_event(ev) else {
165+
continue;
166+
};
167+
counts.insert(d, resp.members.len() as i64);
168+
}
169+
counts
170+
}
171+
122172
#[tauri::command]
123173
pub async fn get_channel_details(
124174
channel_id: String,
@@ -415,3 +465,7 @@ pub async fn leave_channel(channel_id: String, state: State<'_, AppState>) -> Re
415465
submit_event(builder, &state).await?;
416466
Ok(())
417467
}
468+
469+
#[cfg(test)]
470+
#[path = "channels_tests.rs"]
471+
mod tests;
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Tests for commands/channels.rs — split into a sibling file to keep
2+
// channels.rs under the per-file line cap.
3+
4+
use super::*;
5+
use nostr::{EventBuilder, Keys, Kind, Tag};
6+
7+
/// Build a signed event for testing with the given kind, content, and tags.
8+
fn ev(kind: u16, content: &str, tags: Vec<Vec<&str>>) -> nostr::Event {
9+
let keys = Keys::generate();
10+
let parsed: Vec<Tag> = tags
11+
.into_iter()
12+
.map(|t| Tag::parse(t).expect("parse tag"))
13+
.collect();
14+
EventBuilder::new(Kind::from_u16(kind), content)
15+
.tags(parsed)
16+
.sign_with_keys(&keys)
17+
.expect("sign")
18+
}
19+
20+
// A 64-hex pubkey (nostr p-tags require 32-byte hex).
21+
const PK_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
22+
const PK_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
23+
const PK_C: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
24+
25+
#[test]
26+
fn counts_unique_p_tags_per_channel() {
27+
let e1 = ev(
28+
39002,
29+
"",
30+
vec![
31+
vec!["d", "chan-1"],
32+
vec!["p", PK_A, "", "member"],
33+
vec!["p", PK_B, "", "admin"],
34+
],
35+
);
36+
let e2 = ev(
37+
39002,
38+
"",
39+
vec![vec!["d", "chan-2"], vec!["p", PK_C, "", "member"]],
40+
);
41+
42+
let counts = count_members_by_channel(&[e1, e2]);
43+
assert_eq!(counts.get("chan-1"), Some(&2));
44+
assert_eq!(counts.get("chan-2"), Some(&1));
45+
assert_eq!(counts.len(), 2);
46+
}
47+
48+
#[test]
49+
fn dedupes_repeated_pubkeys() {
50+
let e = ev(
51+
39002,
52+
"",
53+
vec![
54+
vec!["d", "chan-1"],
55+
vec!["p", PK_A, "", "member"],
56+
vec!["p", PK_A, "", "admin"], // duplicate pubkey, different role
57+
vec!["p", PK_B, "", "member"],
58+
],
59+
);
60+
let counts = count_members_by_channel(&[e]);
61+
assert_eq!(counts.get("chan-1"), Some(&2));
62+
}
63+
64+
#[test]
65+
fn skips_event_without_d_tag() {
66+
let e = ev(39002, "", vec![vec!["p", PK_A, "", "member"]]);
67+
let counts = count_members_by_channel(&[e]);
68+
assert!(counts.is_empty());
69+
}
70+
71+
#[test]
72+
fn zero_member_channel_is_recorded() {
73+
// A channel with a members event but no p-tags should report 0,
74+
// not be absent from the map (the caller relies on `get` returning
75+
// `Some(0)` to overwrite a default).
76+
let e = ev(39002, "", vec![vec!["d", "chan-1"]]);
77+
let counts = count_members_by_channel(&[e]);
78+
assert_eq!(counts.get("chan-1"), Some(&0));
79+
}
80+
81+
#[test]
82+
fn empty_input_yields_empty_map() {
83+
let counts = count_members_by_channel(&[]);
84+
assert!(counts.is_empty());
85+
}

0 commit comments

Comments
 (0)