From 79c529091cdb7b1fcdbb0edd97943f51691b9abf Mon Sep 17 00:00:00 2001 From: Tom Bates Date: Thu, 23 Jul 2026 11:12:41 -0300 Subject: [PATCH] fix(desktop,mobile): user pickers search by substring, not prefix Flip the user-search filters from search_mode="prefix" to "contains" so every picker surface (member add, @mentions, DM recipients, topbar people) matches any typed fragment of a name. Message typeahead stays on prefix. Co-authored-by: Cursor --- desktop/src-tauri/src/commands/profile.rs | 28 ++++++++++++---------- mobile/lib/shared/relay/nostr_filters.dart | 13 +++++----- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index ab36b45c5..e5d3010fd 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -172,7 +172,7 @@ fn build_user_search_filter(query: &str, limit: usize, page: u32) -> serde_json: serde_json::json!({ "kinds": [0], "search": query, - "search_mode": "prefix", + "search_mode": "contains", "limit": limit, "page": page, }) @@ -236,12 +236,14 @@ pub async fn search_users( // than a substring hit in `about`. The caller can request later pages via the // cursor so the UI cap is only a page size, not a terminal directory ceiling. // - // `search_mode: "prefix"` matters: every caller of this command is a + // `search_mode: "contains"` matters: every caller of this command is a // typeahead surface (member picker, @mention popup, DM recipient search, - // topbar people results), so a partially typed name must match. Without it - // the relay runs whole-word `websearch_to_tsquery` matching and "tyl" - // returns zero results for "Tyler". Same bridge-only extension the topbar - // message search uses (see `build_search_messages_filter`). + // topbar people results), so any typed fragment of a name must match. + // Whole-word FTS returns zero results for "tyl" → "Tyler", and prefix + // mode returns zero results for "kurs" → "mattkursmark" because the name + // is a single lexeme. Contains mode is ILIKE substring matching on the + // relay (same bridge-only `search_mode` extension family the topbar + // message search uses via `build_search_messages_filter`). let events = query_relay(&state, &[build_user_search_filter(trimmed, max, page)]).await?; let mut response = nostr_convert::rank_user_search_results(&events, trimmed, max); @@ -336,15 +338,17 @@ mod tests { use super::*; #[test] - fn user_search_filter_requests_prefix_mode_for_typeahead() { + fn user_search_filter_requests_contains_mode_for_typeahead() { // Every caller of `search_users` is a typeahead surface. Whole-word // FTS matching returns zero results for a partially typed name - // ("tyl" for "Tyler"), which reads as "user doesn't exist" in the - // member picker and @mention popup. Pin the mode so it can't drift. - let filter = build_user_search_filter("tyl", 25, 1); + // ("tyl" for "Tyler"), and prefix matching returns zero results for + // a mid-name fragment ("kurs" for "mattkursmark"), which reads as + // "user doesn't exist" in the member picker and @mention popup. Pin + // the mode so it can't drift. + let filter = build_user_search_filter("kurs", 25, 1); - assert_eq!(filter["search"], serde_json::json!("tyl")); - assert_eq!(filter["search_mode"], serde_json::json!("prefix")); + assert_eq!(filter["search"], serde_json::json!("kurs")); + assert_eq!(filter["search_mode"], serde_json::json!("contains")); assert_eq!(filter["limit"], serde_json::json!(25)); assert_eq!(filter["page"], serde_json::json!(1)); } diff --git a/mobile/lib/shared/relay/nostr_filters.dart b/mobile/lib/shared/relay/nostr_filters.dart index 5cea037f9..71aeef672 100644 --- a/mobile/lib/shared/relay/nostr_filters.dart +++ b/mobile/lib/shared/relay/nostr_filters.dart @@ -134,16 +134,17 @@ abstract final class NostrFilters { /// Global user search over kind:0 profiles (NIP-50 via the HTTP bridge). /// - /// `search_mode: "prefix"` is a Buzz bridge-only extension: every caller is - /// a typeahead surface, so a partially typed name must match ("rac" → - /// "raccoon"). Mirrors desktop's `build_user_search_filter` - /// (desktop/src-tauri/src/commands/profile.rs). Bridge-only — send through - /// `queryRelay`, not a WebSocket REQ. + /// `search_mode: "contains"` is a Buzz bridge-only extension: every caller + /// is a typeahead surface, so any typed fragment of a name must match + /// ("kurs" → "mattkursmark" — prefix matching misses mid-name fragments + /// because names are single FTS lexemes). Mirrors desktop's + /// `build_user_search_filter` (desktop/src-tauri/src/commands/profile.rs). + /// Bridge-only — send through `queryRelay`, not a WebSocket REQ. static NostrFilter searchUsers(String query, {int limit = 50}) => NostrFilter( kinds: [0], search: query, limit: limit, - extensions: const {'search_mode': 'prefix'}, + extensions: const {'search_mode': 'contains'}, ); /// Deletions (kind:5) targeting event IDs.