diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index fc06c186c..a1ea1a96e 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use reqwest::Method; +use serde_json::{Map, Value}; use sprout_core::PresenceStatus; use tauri::State; @@ -16,13 +17,14 @@ use crate::{ #[tauri::command] pub async fn get_profile(state: State<'_, AppState>) -> Result { + let fallback_pubkey = current_pubkey_hex(&state)?; let request = build_authed_request( &state.http_client, Method::GET, "/api/users/me/profile", &state, )?; - send_json_request(request).await + fetch_profile_info(request, &fallback_pubkey, true).await } #[tauri::command] @@ -48,29 +50,30 @@ pub async fn update_profile( let dn = display_name .as_deref() - .or_else(|| current.get("display_name").and_then(|v| v.as_str())); - let name = current.get("name").and_then(|v| v.as_str()); + .or_else(|| profile_field_str(¤t, "display_name")); + let name = profile_field_str(¤t, "name"); let picture = avatar_url .as_deref() - .or_else(|| current.get("avatar_url").and_then(|v| v.as_str())); + .or_else(|| profile_field_str(¤t, "avatar_url")); let ab = about .as_deref() - .or_else(|| current.get("about").and_then(|v| v.as_str())); + .or_else(|| profile_field_str(¤t, "about")); let nip05 = nip05_handle .as_deref() - .or_else(|| current.get("nip05_handle").and_then(|v| v.as_str())); + .or_else(|| profile_field_str(¤t, "nip05_handle")); let builder = events::build_profile(dn, name, picture, ab, nip05)?; submit_event(builder, &state).await?; // Re-fetch to return the canonical profile the frontend expects. + let fallback_pubkey = current_pubkey_hex(&state)?; let request = build_authed_request( &state.http_client, Method::GET, "/api/users/me/profile", &state, )?; - send_json_request(request).await + fetch_profile_info(request, &fallback_pubkey, true).await } // ── Unchanged reads below ──────────────────────────────────────────────────── @@ -80,12 +83,16 @@ pub async fn get_user_profile( pubkey: Option, state: State<'_, AppState>, ) -> Result { - let path = match pubkey { - Some(pubkey) => api_path(&["users", &pubkey, "profile"]), + let path = match pubkey.as_deref() { + Some(pubkey) => api_path(&["users", pubkey, "profile"]), None => "/api/users/me/profile".to_string(), }; + let fallback_pubkey = match pubkey { + Some(pubkey) => pubkey, + None => current_pubkey_hex(&state)?, + }; let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - send_json_request(request).await + fetch_profile_info(request, &fallback_pubkey, false).await } #[tauri::command] @@ -160,3 +167,169 @@ pub async fn set_presence( .json(&SetPresenceBody { status }); send_json_request(request).await } + +fn current_pubkey_hex(state: &AppState) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + Ok(keys.public_key().to_hex()) +} + +fn empty_profile_info(pubkey: &str) -> ProfileInfo { + ProfileInfo { + pubkey: pubkey.to_string(), + display_name: None, + avatar_url: None, + about: None, + nip05_handle: None, + } +} + +fn is_missing_profile_error(error: &str) -> bool { + error.starts_with("relay returned 404") && error.contains("user not found") +} + +fn profile_object(value: &Value) -> Option<&Map> { + value + .get("profile") + .and_then(Value::as_object) + .or_else(|| value.as_object()) +} + +fn profile_field_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + profile_object(value) + .and_then(|object| object.get(key)) + .and_then(Value::as_str) +} + +fn optional_profile_string( + object: &Map, + key: &str, +) -> Result, String> { + match object.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(format!("parse failed: invalid profile field `{key}`")), + } +} + +fn profile_info_from_value(value: Value, fallback_pubkey: &str) -> Result { + let object = profile_object(&value) + .ok_or_else(|| "parse failed: expected profile object".to_string())?; + + let pubkey = match object.get("pubkey") { + None | Some(Value::Null) => fallback_pubkey.to_string(), + Some(Value::String(value)) => value.clone(), + Some(_) => return Err("parse failed: invalid profile field `pubkey`".to_string()), + }; + + Ok(ProfileInfo { + pubkey, + display_name: optional_profile_string(object, "display_name")?, + avatar_url: optional_profile_string(object, "avatar_url")?, + about: optional_profile_string(object, "about")?, + nip05_handle: optional_profile_string(object, "nip05_handle")?, + }) +} + +async fn fetch_profile_info( + request: reqwest::RequestBuilder, + fallback_pubkey: &str, + missing_profile_ok: bool, +) -> Result { + match send_json_request::(request).await { + Ok(value) => profile_info_from_value(value, fallback_pubkey), + Err(error) if missing_profile_ok && is_missing_profile_error(&error) => { + Ok(empty_profile_info(fallback_pubkey)) + } + Err(error) => Err(error), + } +} + +#[cfg(test)] +mod tests { + use super::{ + empty_profile_info, is_missing_profile_error, profile_field_str, profile_info_from_value, + }; + + #[test] + fn profile_info_from_value_accepts_top_level_shape() { + let value = serde_json::json!({ + "pubkey": "abc123", + "display_name": "Sprout User", + "avatar_url": "https://example.com/avatar.png", + "about": "Hello", + "nip05_handle": "sprout@example.com" + }); + + let profile = profile_info_from_value(value, "fallback").expect("profile"); + + assert_eq!(profile.pubkey, "abc123"); + assert_eq!(profile.display_name.as_deref(), Some("Sprout User")); + assert_eq!( + profile.avatar_url.as_deref(), + Some("https://example.com/avatar.png") + ); + assert_eq!(profile.about.as_deref(), Some("Hello")); + assert_eq!(profile.nip05_handle.as_deref(), Some("sprout@example.com")); + } + + #[test] + fn profile_info_from_value_accepts_nested_profile_shape() { + let value = serde_json::json!({ + "profile": { + "display_name": "Nested User", + "avatar_url": null, + "about": null, + "nip05_handle": null + } + }); + + let profile = profile_info_from_value(value, "fallback-pubkey").expect("profile"); + + assert_eq!(profile.pubkey, "fallback-pubkey"); + assert_eq!(profile.display_name.as_deref(), Some("Nested User")); + assert_eq!(profile.avatar_url, None); + assert_eq!(profile.about, None); + assert_eq!(profile.nip05_handle, None); + } + + #[test] + fn missing_profile_errors_are_detected() { + assert!(is_missing_profile_error( + "relay returned 404: user not found" + )); + assert!(is_missing_profile_error( + "relay returned 404 Not Found: user not found" + )); + assert!(!is_missing_profile_error( + "relay returned 401: authentication failed" + )); + } + + #[test] + fn profile_field_str_accepts_nested_profile_shape() { + let value = serde_json::json!({ + "profile": { + "display_name": "Nested User", + "about": "Nested about" + } + }); + + assert_eq!( + profile_field_str(&value, "display_name"), + Some("Nested User") + ); + assert_eq!(profile_field_str(&value, "about"), Some("Nested about")); + assert_eq!(profile_field_str(&value, "avatar_url"), None); + } + + #[test] + fn empty_profile_info_preserves_pubkey() { + let profile = empty_profile_info("fallback-pubkey"); + + assert_eq!(profile.pubkey, "fallback-pubkey"); + assert_eq!(profile.display_name, None); + assert_eq!(profile.avatar_url, None); + assert_eq!(profile.about, None); + assert_eq!(profile.nip05_handle, None); + } +}