mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user