diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index dd00ab1ff4..d860ef8f6a 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1205,8 +1205,9 @@ impl Db { community_id: CommunityId, query: &str, limit: u32, + offset: u32, ) -> Result> { - user::search_users(&self.pool, community_id, query, limit).await + user::search_users(&self.pool, community_id, query, limit, offset).await } /// Atomically set agent owner — only if no owner is currently assigned. diff --git a/crates/buzz-db/src/user.rs b/crates/buzz-db/src/user.rs index a4a5a9d7e8..8f57144552 100644 --- a/crates/buzz-db/src/user.rs +++ b/crates/buzz-db/src/user.rs @@ -217,11 +217,16 @@ fn escape_like(input: &str) -> String { /// Search users by display name, NIP-05 handle, or pubkey prefix. /// /// Empty queries return an empty vec and do not hit the database. +/// +/// `offset` is 0-based and used for bridge people-directory paging (the desktop +/// member picker pages with `page`/`limit`). Clamped so a huge offset cannot +/// force an unbounded scan beyond the page window. pub async fn search_users( pool: &PgPool, community_id: CommunityId, query: &str, limit: u32, + offset: u32, ) -> Result> { let normalized = query.trim().to_lowercase(); if normalized.is_empty() { @@ -232,6 +237,7 @@ pub async fn search_users( let contains_pattern = format!("%{escaped}%"); let prefix_pattern = format!("{escaped}%"); let limit = limit.clamp(1, 500) as i64; + let offset = offset.min(100_000) as i64; let rows = sqlx::query_as::<_, (Vec, Option, Option, Option)>( r#" @@ -252,7 +258,7 @@ pub async fn search_users( ELSE 6 END, COALESCE(NULLIF(display_name, ''), NULLIF(nip05_handle, ''), LOWER(encode(pubkey, 'hex'))) - LIMIT $5 + LIMIT $5 OFFSET $6 "#, ) .bind(community_id.as_uuid()) @@ -260,6 +266,7 @@ pub async fn search_users( .bind(&normalized) .bind(&prefix_pattern) .bind(limit) + .bind(offset) .fetch_all(pool) .await?; diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 58cb2d09e5..b9883a1cfb 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1289,8 +1289,187 @@ fn search_hit_accepted( true } +/// True when this filter is a pure people-directory search: only kind:0, no +/// authors / time / tag constraints. Those queries should hit the `users` +/// table (display name, NIP-05 handle, pubkey hex) rather than FTS over kind:0 +/// JSON content — FTS never indexes the author pubkey, so searching by hex +/// always returned zero results and aliases only matched when they happened to +/// appear as whole tokens inside the content blob. +fn is_people_directory_search(filter: &nostr::Filter) -> bool { + let Some(kinds) = filter.kinds.as_ref() else { + return false; + }; + if kinds.len() != 1 || kinds.iter().next().map(|k| k.as_u16()) != Some(0) { + return false; + } + if filter.authors.as_ref().is_some_and(|a| !a.is_empty()) { + return false; + } + if filter.ids.as_ref().is_some_and(|i| !i.is_empty()) { + return false; + } + if filter.since.is_some() || filter.until.is_some() { + return false; + } + // Any single-letter tag constraint means this is not a directory listing. + if !filter.generic_tags.is_empty() { + return false; + } + true +} + +/// Build a synthetic kind:0 profile event from a `users` row so existing +/// desktop/mobile parsers (`user_search_result_from_event`, mobile +/// `DirectoryUser`) keep working without a new response shape. +/// +/// The event is **not** re-signed as the subject — HTTP bridge clients +/// deserialize without verifying (see desktop `query_relay` / mobile +/// fetchHistory). The `pubkey` field must be the subject so typeahead +/// ranking and add-member target the right identity. Id/sig are +/// deterministic placeholders derived from the subject pubkey. +fn synthetic_kind0_from_user(user: &buzz_db::user::UserSearchProfile) -> Option { + if user.pubkey.len() != 32 { + return None; + } + + let mut content = serde_json::Map::new(); + if let Some(name) = user + .display_name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + content.insert( + "display_name".to_string(), + Value::String(name.to_string()), + ); + content.insert("name".to_string(), Value::String(name.to_string())); + } + if let Some(picture) = user + .avatar_url + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + content.insert("picture".to_string(), Value::String(picture.to_string())); + } + if let Some(nip05) = user + .nip05_handle + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + content.insert("nip05".to_string(), Value::String(nip05.to_string())); + } + + // Deterministic 32-byte id/sig placeholders so clients can dedupe and + // deserialize. Not cryptographically meaningful — never submitted. + let mut id_bytes = [0u8; 32]; + id_bytes[0] = 0x5a; // marker: synthetic people-directory row + id_bytes[1..].copy_from_slice(&user.pubkey[..31]); + let mut sig_bytes = [0u8; 64]; + sig_bytes[..32].copy_from_slice(&user.pubkey); + sig_bytes[32..].copy_from_slice(&user.pubkey); + + let event_json = serde_json::json!({ + "id": hex::encode(id_bytes), + "pubkey": hex::encode(&user.pubkey), + "created_at": 0, + "kind": 0, + "tags": [], + "content": Value::Object(content).to_string(), + "sig": hex::encode(sig_bytes), + }); + + serde_json::from_value(event_json).ok() +} + +/// People-directory path: search the `users` table and return synthetic kind:0 +/// events. Prefer real stored kind:0 events when present so NIP-OA auth tags +/// (agent ownership) survive; fall back to the synthetic event otherwise. +async fn handle_people_directory_search( + state: &AppState, + tenant: &buzz_core::tenant::TenantContext, + search_text: &str, + limit: u32, + page: u32, + seen_ids: &mut std::collections::HashSet<[u8; 32]>, + events: &mut Vec, +) -> Result<(), (StatusCode, Json)> { + let offset = page.saturating_sub(1).saturating_mul(limit); + let rows = state + .db + .search_users(tenant.community(), search_text, limit, offset) + .await + .map_err(|e| internal_error(&format!("people directory search error: {e}")))?; + + if rows.is_empty() { + return Ok(()); + } + + // Prefer real kind:0 events when they exist so agent auth tags remain. + let authors: Vec> = rows.iter().map(|u| u.pubkey.clone()).collect(); + let mut query = buzz_db::event::EventQuery::for_community(tenant.community()); + query.kinds = Some(vec![0]); + query.authors = Some(authors); + query.global_only = true; + // One latest profile per author is enough; pull a bit of headroom. + query.limit = Some((rows.len() as i64).saturating_mul(2).max(limit as i64)); + + let stored = state + .db + .query_events(&query) + .await + .map_err(|e| internal_error(&format!("people directory profile fetch error: {e}")))?; + + // Keep the newest kind:0 per author. + let mut latest_by_author: std::collections::HashMap, &buzz_core::StoredEvent> = + std::collections::HashMap::new(); + for se in &stored { + let key = se.event.pubkey.to_bytes().to_vec(); + match latest_by_author.get(&key) { + Some(prev) if prev.event.created_at >= se.event.created_at => {} + _ => { + latest_by_author.insert(key, se); + } + } + } + + for user in &rows { + if let Some(se) = latest_by_author.get(&user.pubkey) { + let id = se.event.id.to_bytes(); + if !seen_ids.insert(id) { + continue; + } + if let Ok(v) = serde_json::to_value(&se.event) { + events.push(v); + } + continue; + } + + // No stored kind:0 — synthesize from the users-table row so pubkey / + // alias / display_name search still surfaces the person. + let Some(event) = synthetic_kind0_from_user(user) else { + continue; + }; + let id = event.id.to_bytes(); + if !seen_ids.insert(id) { + continue; + } + if let Ok(v) = serde_json::to_value(&event) { + events.push(v); + } + } + + Ok(()) +} + /// Handle search filters by routing to Postgres FTS, then fetching full events /// from DB. Supports a bridge-only `page` extension over the FTS result set. +/// +/// Pure kind:0 people-directory filters take a dedicated path through the +/// `users` table so display name / NIP-05 / pubkey-hex typeahead works even +/// when the author has no searchable kind:0 content blob. async fn handle_bridge_search( state: &AppState, raw_filters: &[Value], @@ -1327,6 +1506,23 @@ async fn handle_bridge_search( continue; } + // People directory (member picker / DM / @mention / topbar people): + // only kind:0, no other NIP-01 constraints. Route to the users table + // so pubkey hex and aliases resolve even without a content FTS hit. + if is_people_directory_search(filter) { + handle_people_directory_search( + state, + tenant, + &search_text, + limit, + search_page, + &mut seen_ids, + &mut events, + ) + .await?; + continue; + } + // Scope by channel — push the #h tag (intersected with accessible // channels) if present, else the community-wide scope. let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); @@ -2874,4 +3070,39 @@ mod tests { "owner must still receive their own snapshot" ); } + + #[test] + fn people_directory_search_detects_pure_kind0() { + let pure = nostr::Filter::new().kind(Kind::Metadata).search("alice"); + assert!(is_people_directory_search(&pure)); + + let with_author = nostr::Filter::new() + .kind(Kind::Metadata) + .author(Keys::generate().public_key()) + .search("alice"); + assert!(!is_people_directory_search(&with_author)); + + let messages = nostr::Filter::new() + .kind(Kind::Custom(9)) + .search("alice"); + assert!(!is_people_directory_search(&messages)); + } + + #[test] + fn synthetic_kind0_uses_subject_pubkey_and_profile_fields() { + let pubkey = Keys::generate().public_key().to_bytes().to_vec(); + let user = buzz_db::user::UserSearchProfile { + pubkey: pubkey.clone(), + display_name: Some("Alice".into()), + avatar_url: Some("https://example.com/a.png".into()), + nip05_handle: Some("alice@example.com".into()), + }; + let event = synthetic_kind0_from_user(&user).expect("synthetic event"); + assert_eq!(event.kind, Kind::Metadata); + assert_eq!(event.pubkey.to_bytes().to_vec(), pubkey); + let content: Value = serde_json::from_str(&event.content).expect("json content"); + assert_eq!(content["display_name"], "Alice"); + assert_eq!(content["nip05"], "alice@example.com"); + assert_eq!(content["picture"], "https://example.com/a.png"); + } } diff --git a/desktop/src-tauri/src/nostr_convert/user_search.rs b/desktop/src-tauri/src/nostr_convert/user_search.rs index 43b4288abb..45b8d0d810 100644 --- a/desktop/src-tauri/src/nostr_convert/user_search.rs +++ b/desktop/src-tauri/src/nostr_convert/user_search.rs @@ -164,6 +164,7 @@ fn match_score(q: &str, display_name: &str, nip05: &str, pubkey_hex: &str) -> u3 const NIP05_PREFIX: u32 = 600; const NIP05_CONTAINS: u32 = 500; const PUBKEY_PREFIX: u32 = 400; + const PUBKEY_CONTAINS: u32 = 300; let score_field = |field: &str, exact: u32, prefix: u32, contains: u32| -> u32 { if field.is_empty() { @@ -186,11 +187,7 @@ fn match_score(q: &str, display_name: &str, nip05: &str, pubkey_hex: &str) -> u3 DISPLAY_CONTAINS, ); let nip05_score = score_field(nip05, NIP05_EXACT, NIP05_PREFIX, NIP05_CONTAINS); - let pubkey_score = if !pubkey_hex.is_empty() && pubkey_hex.starts_with(q) { - PUBKEY_PREFIX - } else { - 0 - }; + let pubkey_score = score_field(pubkey_hex, PUBKEY_PREFIX, PUBKEY_PREFIX, PUBKEY_CONTAINS); display_score.max(nip05_score).max(pubkey_score) } @@ -320,6 +317,19 @@ mod tests { assert!(r.users.is_empty()); } + #[test] + fn rank_matches_mid_string_pubkey_hex() { + let keys = nostr::Keys::generate(); + let pubkey = keys.public_key().to_hex(); + let needle = &pubkey[8..16]; + let e = EventBuilder::new(Kind::Metadata, r#"{"display_name":"Bob"}"#) + .sign_with_keys(&keys) + .unwrap(); + let r = rank_user_search_results(&[e], needle, 10); + assert_eq!(r.users.len(), 1); + assert_eq!(r.users[0].pubkey, pubkey); + } + #[test] fn rank_exact_display_name_beats_substring_match() { let exact = ev(0, r#"{"display_name":"alice"}"#, vec![]);