Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions desktop/src-tauri/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Comment on lines +131 to +133
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict member-count query to relay-signed 39002 events

The new get_channels member-count fetch filters only by kinds and #d, so any kind:39002 event with a matching d tag is accepted regardless of author. Because count_members_by_channel trusts these events, a non-relay publisher can inject a forged members event (same channel id, inflated p tags) and the browser will show an incorrect member_count. This is especially reachable because 39002 is not treated as relay-only in ingest; please constrain this query to the trusted relay author (or the same author as the channel metadata event) before counting.

Useful? React with 👍 / 👎.

})],
)
.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,
Expand Down Expand Up @@ -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;
85 changes: 85 additions & 0 deletions desktop/src-tauri/src/commands/channels_tests.rs
Original file line number Diff line number Diff line change
@@ -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());
}
Loading