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
58 changes: 58 additions & 0 deletions desktop/src-tauri/src/commands/profile.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::collections::HashMap;

use buzz_core_pkg::PresenceStatus;
use nostr::PublicKey;
use serde_json::Value;
use tauri::State;

Expand Down Expand Up @@ -261,6 +262,17 @@ fn build_user_search_filter(query: &str, limit: usize, page: u32) -> serde_json:
})
}

fn parse_exact_pubkey_query(query: &str) -> Option<PublicKey> {
let trimmed = query.trim();
let is_hex = trimmed.len() == 64 && trimmed.bytes().all(|byte| byte.is_ascii_hexdigit());
let is_npub = trimmed.to_ascii_lowercase().starts_with("npub1");
if !is_hex && !is_npub {
return None;
}

PublicKey::parse(trimmed).ok()
}

#[tauri::command]
pub async fn search_users(
query: String,
Expand Down Expand Up @@ -306,6 +318,32 @@ pub async fn search_users(
return Ok(response);
}

if let Some(target) = parse_exact_pubkey_query(trimmed) {
let target_hex = target.to_hex();
let events = query_relay(
&state,
&[
serde_json::json!({
"kinds": [0],
"authors": [target_hex],
"limit": 1
}),
serde_json::json!({
"kinds": [13534],
"limit": 1
}),
],
)
.await?;
let users = nostr_convert::exact_user_search_result(&events, &target)
.into_iter()
.collect();
return Ok(SearchUsersResponse {
users,
next_cursor: None,
});
}

// NIP-50 full-text search on kind:0 profiles. The relay's HTTP bridge
// intercepts the `search` field on POST /query and routes to Postgres FTS
// (see `crates/buzz-relay/src/api/bridge.rs::handle_bridge_search`),
Expand Down Expand Up @@ -417,6 +455,7 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo {
#[cfg(test)]
mod tests {
use super::*;
use nostr::ToBech32;

#[test]
fn deferred_profile_signer_is_captured_and_rejects_wrong_identity() {
Expand Down Expand Up @@ -481,4 +520,23 @@ mod tests {
assert_eq!(filter["limit"], serde_json::json!(25));
assert_eq!(filter["page"], serde_json::json!(1));
}

#[test]
fn exact_pubkey_query_accepts_hex_and_npub() {
let public_key = nostr::Keys::generate().public_key();
let hex = public_key.to_hex();
let npub = public_key.to_bech32().expect("encode npub");

assert_eq!(parse_exact_pubkey_query(&hex), Some(public_key));
assert_eq!(parse_exact_pubkey_query(&npub), Some(public_key));
}

#[test]
fn exact_pubkey_query_rejects_prefixes_and_other_text() {
let public_key = nostr::Keys::generate().public_key().to_hex();

assert_eq!(parse_exact_pubkey_query(&public_key[..16]), None);
assert_eq!(parse_exact_pubkey_query("alice"), None);
assert_eq!(parse_exact_pubkey_query("note1invalid"), None);
}
}
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/nostr_convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ use crate::models::*;

mod user_search;
pub use user_search::{
list_user_search_results, rank_user_search_results, search_users_from_events,
user_search_result_from_event,
exact_user_search_result, list_user_search_results, rank_user_search_results,
search_users_from_events, user_search_result_from_event,
};

// ── Tag helpers ─────────────────────────────────────────────────────────────
Expand Down
115 changes: 114 additions & 1 deletion desktop/src-tauri/src/nostr_convert/user_search.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::collections::{HashMap, HashSet};

use nostr::Event;
use nostr::{Event, PublicKey};
use serde_json::Value;

use crate::models::{SearchUsersResponse, UserSearchResultInfo};
Expand Down Expand Up @@ -34,6 +34,53 @@ pub fn search_users_from_events(events: &[Event]) -> SearchUsersResponse {
}
}

/// Resolve an exact pubkey query from profile and relay-membership events.
///
/// A NIP-43 community member may not have published kind:0 metadata yet. In
/// that case the membership snapshot is still authoritative enough to expose a
/// bare-key search result. When a snapshot exists, identities outside it fail
/// closed; when no snapshot exists, the relay is treated as open.
pub fn exact_user_search_result(
events: &[Event],
target: &PublicKey,
) -> Option<UserSearchResultInfo> {
let target_hex = target.to_hex();
let membership_event = events
.iter()
.filter(|event| event.kind.as_u16() == 13534)
.max_by_key(|event| event.created_at);

if membership_event.is_some_and(|event| !relay_membership_contains(event, &target_hex)) {
return None;
}

events
.iter()
.filter(|event| event.kind.as_u16() == 0 && event.pubkey == *target)
.max_by_key(|event| event.created_at)
.map(user_search_result_from_event)
.or(Some(UserSearchResultInfo {
pubkey: target_hex,
display_name: None,
avatar_url: None,
nip05_handle: None,
owner_pubkey: None,
is_agent: false,
}))
}

fn relay_membership_contains(event: &Event, target_hex: &str) -> bool {
event.tags.iter().any(|tag| {
let values = tag.as_slice();
matches!(
values.first().map(String::as_str),
Some("member") | Some("p")
) && values
.get(1)
.is_some_and(|pubkey| pubkey.eq_ignore_ascii_case(target_hex))
})
}

/// Convert a default kind:0 page to user-search results for empty-query pickers.
pub fn list_user_search_results(events: &[Event], limit: usize) -> SearchUsersResponse {
if limit == 0 {
Expand Down Expand Up @@ -228,6 +275,24 @@ mod tests {
.expect("sign")
}

fn membership_event(members: &[&str]) -> Event {
let tags = members
.iter()
.map(|pubkey| {
Tag::parse(vec![
"member".to_string(),
(*pubkey).to_string(),
"member".to_string(),
])
.expect("membership tag")
})
.collect::<Vec<_>>();
EventBuilder::new(Kind::from_u16(13534), "")
.tags(tags)
.sign_with_keys(&nostr::Keys::generate())
.expect("sign membership")
}

#[test]
fn search_users_maps_each_event() {
let e1 = ev(0, r#"{"name":"a"}"#, vec![]);
Expand All @@ -238,6 +303,54 @@ mod tests {
assert_eq!(r.users[1].display_name.as_deref(), Some("B"));
}

#[test]
fn exact_search_returns_profileless_relay_member() {
let target = nostr::Keys::generate().public_key();
let membership = membership_event(&[&target.to_hex()]);

let result = exact_user_search_result(std::slice::from_ref(&membership), &target).unwrap();

assert_eq!(result.pubkey, target.to_hex());
assert_eq!(result.display_name, None);
assert_eq!(result.avatar_url, None);
assert_eq!(result.nip05_handle, None);
assert!(!result.is_agent);
}

#[test]
fn exact_search_preserves_profile_for_relay_member() {
let keys = nostr::Keys::generate();
let target = keys.public_key();
let profile = EventBuilder::metadata(&nostr::Metadata::new().name("Ada"))
.sign_with_keys(&keys)
.expect("sign profile");
let membership = membership_event(&[&target.to_hex()]);

let result = exact_user_search_result(&[profile, membership], &target).unwrap();

assert_eq!(result.pubkey, target.to_hex());
assert_eq!(result.display_name.as_deref(), Some("Ada"));
}

#[test]
fn exact_search_rejects_non_member_when_snapshot_exists() {
let member = nostr::Keys::generate().public_key();
let outsider = nostr::Keys::generate().public_key();
let membership = membership_event(&[&member.to_hex()]);

assert!(exact_user_search_result(&[membership], &outsider).is_none());
}

#[test]
fn exact_search_allows_bare_key_when_relay_has_no_membership_snapshot() {
let target = nostr::Keys::generate().public_key();

let result = exact_user_search_result(&[], &target).unwrap();

assert_eq!(result.pubkey, target.to_hex());
assert_eq!(result.display_name, None);
}

#[test]
fn user_search_result_marks_valid_nip_oa_profile_as_agent() {
let event = oa_profile_event(r#"{"display_name":"Mira"}"#);
Expand Down
10 changes: 10 additions & 0 deletions desktop/src/features/profile/lib/userCandidateSearch.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
rankUserCandidatesBySearch,
scoreUserCandidate,
} from "./userCandidateSearch.ts";
import { pubkeyToNpub } from "../../../shared/lib/nostrUtils.ts";

function makeUser(overrides = {}) {
return {
Expand Down Expand Up @@ -47,6 +48,15 @@ test("scoreUserCandidate ranks display labels before pubkeys", () => {
);
});

test("scoreUserCandidate matches the canonical npub", () => {
const pubkey =
"50d0af578a29c245c08c9e2ad95be422ba6e3a9df938dcf6b8863ebcdec399db";
const npub = pubkeyToNpub(pubkey);
const user = makeUser({ pubkey });

assert.equal(scoreUserCandidate({ label: pubkey, query: npub, user }), 3);
});

test("scoreUserCandidate supports agent labels and empty-query defaults", () => {
const agent = makeUser({ isAgent: true });

Expand Down
5 changes: 5 additions & 0 deletions desktop/src/features/profile/lib/userCandidateSearch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { UserSearchResult } from "@/shared/api/types";
import { safeNpub } from "@/shared/lib/nostrUtils";
import { normalizePubkey } from "@/shared/lib/pubkey";

type ScoreUserCandidateInput = {
Expand Down Expand Up @@ -55,6 +56,10 @@ export function scoreUserCandidate({
if (pubkey.startsWith(normalizedQuery)) return 3;
if (pubkey.includes(normalizedQuery)) return 4;

const npub = safeNpub(pubkey)?.toLowerCase();
if (npub?.startsWith(normalizedQuery)) return 3;
if (npub?.includes(normalizedQuery)) return 4;

return null;
}

Expand Down