Fix profile settings first launch parse failure (#383)

Signed-off-by: luthen-seas <229396760+luthen-seas@users.noreply.github.com>
Co-authored-by: luthen-seas <229396760+luthen-seas@users.noreply.github.com>
This commit is contained in:
luthen-seas
2026-04-24 10:35:19 -07:00
committed by GitHub
co-authored by luthen-seas
parent 353729a56d
commit ee76396d00
+183 -10
View File
@@ -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<ProfileInfo, String> {
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(&current, "display_name"));
let name = profile_field_str(&current, "name");
let picture = avatar_url
.as_deref()
.or_else(|| current.get("avatar_url").and_then(|v| v.as_str()));
.or_else(|| profile_field_str(&current, "avatar_url"));
let ab = about
.as_deref()
.or_else(|| current.get("about").and_then(|v| v.as_str()));
.or_else(|| profile_field_str(&current, "about"));
let nip05 = nip05_handle
.as_deref()
.or_else(|| current.get("nip05_handle").and_then(|v| v.as_str()));
.or_else(|| profile_field_str(&current, "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<String>,
state: State<'_, AppState>,
) -> Result<ProfileInfo, String> {
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<String, String> {
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<String, Value>> {
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<String, Value>,
key: &str,
) -> Result<Option<String>, 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<ProfileInfo, String> {
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<ProfileInfo, String> {
match send_json_request::<Value>(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);
}
}