Skip to content
Open
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
3 changes: 2 additions & 1 deletion crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1205,8 +1205,9 @@ impl Db {
community_id: CommunityId,
query: &str,
limit: u32,
offset: u32,
) -> Result<Vec<user::UserSearchProfile>> {
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.
Expand Down
9 changes: 8 additions & 1 deletion crates/buzz-db/src/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<UserSearchProfile>> {
let normalized = query.trim().to_lowercase();
if normalized.is_empty() {
Expand All @@ -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<u8>, Option<String>, Option<String>, Option<String>)>(
r#"
Expand All @@ -252,14 +258,15 @@ 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
Comment on lines 260 to +261

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 Add a deterministic tiebreaker before paging users

This query now supports OFFSET paging, but rows with the same score and label, such as many users named Alex, have no unique ordering before LIMIT/OFFSET. PostgreSQL may return those ties in different orders between page requests, so the desktop next_cursor flow can duplicate users or skip some entirely. Add a stable final sort key such as the pubkey hex before applying the offset.

Useful? React with 👍 / 👎.

"#,
)
.bind(community_id.as_uuid())
.bind(&contains_pattern)
.bind(&normalized)
.bind(&prefix_pattern)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;

Expand Down
231 changes: 231 additions & 0 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<nostr::Event> {
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<Value>,
) -> Result<(), (StatusCode, Json<Value>)> {
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<Vec<u8>> = 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<Vec<u8>, &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 {

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 Avoid resurrecting deleted profiles from cached user rows

When a user deletes their kind:0 profile with NIP-09, handle_standard_deletion_event soft-deletes the event but does not clear the cached users.display_name/avatar/NIP-05 fields. Because the real-profile query ignores deleted rows, this new fallback synthesizes a fresh kind:0 from that stale cache for any matching people search, so deleted profile information reappears in member, DM, and @mention search results. Please either clear/invalidate the user cache on kind:0 deletion or avoid synthesizing from rows whose live profile was deleted.

Useful? React with 👍 / 👎.

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],
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
}
}
20 changes: 15 additions & 5 deletions desktop/src-tauri/src/nostr_convert/user_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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![]);
Expand Down