diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index d39922c812..bd1864e34f 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -697,6 +697,15 @@ impl Db { user::get_user(&self.pool, pubkey).await } + /// Search users by display name, NIP-05 handle, or pubkey prefix. + pub async fn search_users( + &self, + query: &str, + limit: u32, + ) -> Result> { + user::search_users(&self.pool, query, limit).await + } + /// Update a user's display_name, avatar_url, about, and/or nip05_handle. pub async fn update_user_profile( &self, diff --git a/crates/sprout-db/src/user.rs b/crates/sprout-db/src/user.rs index d29498ef92..527eeb6065 100644 --- a/crates/sprout-db/src/user.rs +++ b/crates/sprout-db/src/user.rs @@ -19,6 +19,19 @@ pub struct UserProfile { pub nip05_handle: Option, } +/// Lightweight user record returned from search. +#[derive(Debug, Clone)] +pub struct UserSearchProfile { + /// Raw 32-byte compressed public key. + pub pubkey: Vec, + /// Human-readable display name chosen by the user. + pub display_name: Option, + /// URL of the user's avatar image. + pub avatar_url: Option, + /// NIP-05 identifier (user@domain). + pub nip05_handle: Option, +} + /// Ensure a user record exists for the given pubkey (upsert). /// Creates with minimal fields if not present; no-op if already exists. pub async fn ensure_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<()> { @@ -167,6 +180,70 @@ pub async fn get_user_by_nip05( )) } +/// Search users by display name, NIP-05 handle, or pubkey prefix. +/// +/// Empty queries return an empty vec and do not hit the database. +pub async fn search_users( + pool: &MySqlPool, + query: &str, + limit: u32, +) -> Result> { + let normalized = query.trim().to_lowercase(); + if normalized.is_empty() { + return Ok(Vec::new()); + } + + let contains_pattern = format!("%{normalized}%"); + let prefix_pattern = format!("{normalized}%"); + let limit = limit.clamp(1, 50) as i64; + + let rows = sqlx::query_as::<_, (Vec, Option, Option, Option)>( + r#" + SELECT pubkey, display_name, avatar_url, nip05_handle + FROM users + WHERE LOWER(COALESCE(display_name, '')) LIKE ? + OR LOWER(COALESCE(nip05_handle, '')) LIKE ? + OR LOWER(HEX(pubkey)) LIKE ? + ORDER BY + CASE + WHEN LOWER(COALESCE(display_name, '')) = ? THEN 0 + WHEN LOWER(COALESCE(nip05_handle, '')) = ? THEN 1 + WHEN LOWER(HEX(pubkey)) = ? THEN 2 + WHEN LOWER(COALESCE(display_name, '')) LIKE ? THEN 3 + WHEN LOWER(COALESCE(nip05_handle, '')) LIKE ? THEN 4 + WHEN LOWER(HEX(pubkey)) LIKE ? THEN 5 + ELSE 6 + END, + COALESCE(NULLIF(display_name, ''), NULLIF(nip05_handle, ''), LOWER(HEX(pubkey))) + LIMIT ? + "#, + ) + .bind(&contains_pattern) + .bind(&contains_pattern) + .bind(&contains_pattern) + .bind(&normalized) + .bind(&normalized) + .bind(&normalized) + .bind(&prefix_pattern) + .bind(&prefix_pattern) + .bind(&prefix_pattern) + .bind(limit) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map( + |(pubkey, display_name, avatar_url, nip05_handle)| UserSearchProfile { + pubkey, + display_name, + avatar_url, + nip05_handle, + }, + ) + .collect()) +} + /// Set the owner pubkey for an agent user. /// The owner pubkey must already exist in the users table (FK constraint). /// Returns an error if the agent pubkey is not found (rows_affected == 0). diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 22f60586a4..242fd5aee4 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -70,7 +70,8 @@ pub use presence::{presence_handler, set_presence_handler}; pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler}; pub use search::search_handler; pub use users::{ - get_profile, get_user_profile, get_users_batch, put_channel_add_policy, update_profile, + get_profile, get_user_profile, get_users_batch, put_channel_add_policy, search_users, + update_profile, }; pub use workflows::{ create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_workflow_runs, diff --git a/crates/sprout-relay/src/api/users.rs b/crates/sprout-relay/src/api/users.rs index 8c48a7ca07..bbfc3079a5 100644 --- a/crates/sprout-relay/src/api/users.rs +++ b/crates/sprout-relay/src/api/users.rs @@ -5,11 +5,12 @@ //! PUT /api/users/me/profile — update own profile (display_name, avatar_url, about, nip05_handle) //! GET /api/users/{pubkey}/profile — get any user's profile by pubkey hex //! POST /api/users/batch — resolve display names for multiple pubkeys +//! GET /api/users/search — search users by display name, NIP-05, or pubkey use std::sync::Arc; use axum::{ - extract::{Json as ExtractJson, Path, State}, + extract::{Json as ExtractJson, Path, Query, State}, http::{HeaderMap, StatusCode}, response::Json, }; @@ -173,6 +174,15 @@ pub struct BatchProfilesRequest { pub pubkeys: Vec, } +/// Query string for user search. +#[derive(Debug, Deserialize)] +pub struct SearchUsersQuery { + /// Case-insensitive search query. + pub q: String, + /// Maximum number of results to return. + pub limit: Option, +} + /// `POST /api/users/batch` — resolve profile summaries for multiple pubkeys. pub async fn get_users_batch( State(state): State>, @@ -256,6 +266,38 @@ pub async fn get_users_batch( }))) } +/// `GET /api/users/search` — search users by display name, NIP-05, or pubkey prefix. +pub async fn search_users( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> Result, (StatusCode, Json)> { + let ctx = extract_auth_context(&headers, &state).await?; + sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?; + + let q = query.q.trim(); + if q.is_empty() { + return Ok(Json(serde_json::json!({ "users": [] }))); + } + + let results = state + .db + .search_users(q, query.limit.unwrap_or(8)) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))?; + + Ok(Json(serde_json::json!({ + "users": results.into_iter().map(|user| { + serde_json::json!({ + "pubkey": nostr_hex::encode(&user.pubkey), + "display_name": user.display_name, + "avatar_url": user.avatar_url, + "nip05_handle": user.nip05_handle, + }) + }).collect::>(), + }))) +} + /// Request body for updating channel add policy. #[derive(Debug, Deserialize)] pub struct UpdateChannelAddPolicyBody { diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index 93d8a2928e..79956af1a2 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -161,6 +161,7 @@ pub fn build_router(state: Arc) -> Router { "/api/users/me/channel-add-policy", put(api::put_channel_add_policy), ) + .route("/api/users/search", get(api::search_users)) .route("/api/users/{pubkey}/profile", get(api::get_user_profile)) .route("/api/users/batch", post(api::get_users_batch)) // Feed route diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index 04b76c287c..bf677e4a3a 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -7,8 +7,8 @@ use tauri::State; use crate::{ app_state::AppState, models::{ - GetUsersBatchBody, ProfileInfo, SetPresenceBody, SetPresenceResponse, UpdateProfileBody, - UsersBatchResponse, + GetUsersBatchBody, ProfileInfo, SearchUsersResponse, SetPresenceBody, SetPresenceResponse, + UpdateProfileBody, UsersBatchResponse, }, relay::{build_authed_request, send_empty_request, send_json_request}, }; @@ -82,6 +82,25 @@ pub async fn get_users_batch( send_json_request(request).await } +#[tauri::command] +pub async fn search_users( + query: String, + limit: Option, + state: State<'_, AppState>, +) -> Result { + let limit = limit.unwrap_or(8); + let limit_param = limit.to_string(); + let request = build_authed_request( + &state.http_client, + Method::GET, + "/api/users/search", + &state, + )? + .query(&[("q", query.as_str()), ("limit", limit_param.as_str())]); + + send_json_request(request).await +} + #[tauri::command] pub async fn get_presence( pubkeys: Vec, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5f2af6ae03..a55b015e0c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -27,6 +27,7 @@ pub fn run() { update_profile, get_user_profile, get_users_batch, + search_users, get_presence, set_presence, get_relay_ws_url, diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 9fc8da5ead..21094f1869 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -32,6 +32,19 @@ pub struct UsersBatchResponse { pub missing: Vec, } +#[derive(Serialize, Deserialize)] +pub struct UserSearchResultInfo { + pub pubkey: String, + pub display_name: Option, + pub avatar_url: Option, + pub nip05_handle: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct SearchUsersResponse { + pub users: Vec, +} + #[derive(Serialize, Deserialize)] pub struct SetPresenceResponse { pub status: PresenceStatus, diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index ce2402236c..2137aa372b 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -10,7 +10,6 @@ import { MessageSquare, Shield, User, - UserPlus, Users, } from "lucide-react"; import * as React from "react"; @@ -43,6 +42,7 @@ import { SheetTitle, } from "@/shared/ui/sheet"; import { Textarea } from "@/shared/ui/textarea"; +import { ChannelMemberInviteCard } from "./ChannelMemberInviteCard"; type ChannelManagementSheetProps = { channel: Channel | null; @@ -52,13 +52,6 @@ type ChannelManagementSheetProps = { open: boolean; }; -const roleOptions: Array> = [ - "member", - "admin", - "guest", - "bot", -]; - const roleOrder: Record = { owner: 0, admin: 1, @@ -197,9 +190,6 @@ export function ChannelManagementSheet({ const [descriptionDraft, setDescriptionDraft] = React.useState(""); const [topicDraft, setTopicDraft] = React.useState(""); const [purposeDraft, setPurposeDraft] = React.useState(""); - const [invitePubkeys, setInvitePubkeys] = React.useState(""); - const [inviteRole, setInviteRole] = - React.useState>("member"); // Sync drafts from server only when the sheet opens or the channel changes — // not on every background refetch, which would clobber in-flight edits. @@ -232,11 +222,6 @@ export function ChannelManagementSheet({ const resolvedChannel = detail ?? channel; - const parsedInvitePubkeys = invitePubkeys - .split(/[\s,]+/) - .map((value) => value.trim()) - .filter((value) => value.length > 0); - return ( {canManageChannel && resolvedChannel.channelType !== "dm" ? ( -
{ - event.preventDefault(); - void addMembersMutation - .mutateAsync({ - pubkeys: parsedInvitePubkeys, - role: inviteRole, - }) - .then((result) => { - if (result.errors.length === 0) { - setInvitePubkeys(""); - } - }); - }} - > -
- - Invite members -
-