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
13 changes: 12 additions & 1 deletion desktop/src-tauri/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,18 @@ pub async fn get_channel_members(
profile_map.insert(
pk,
(
profile.display_name,
profile
.display_name
.and_then(|value| {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
})
.or_else(|| {
profile.name.and_then(|value| {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
})
}),
nostr_convert::profile_has_valid_oa_owner(ev),
),
);
Expand Down
180 changes: 142 additions & 38 deletions desktop/src-tauri/src/commands/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub async fn get_profile(state: State<'_, AppState>) -> Result<ProfileInfo, Stri
#[tauri::command]
pub async fn update_profile(
display_name: Option<String>,
name: Option<String>,
avatar_url: Option<String>,
about: Option<String>,
nip05_handle: Option<String>,
Expand All @@ -56,28 +57,22 @@ pub async fn update_profile(
)
.await?;

// Pull the current content as a JSON object so we can merge with
// the caller's overrides.
let current: Value = prior_events
.first()
.and_then(|ev| serde_json::from_str::<Value>(&ev.content).ok())
.unwrap_or(Value::Null);

let dn = display_name
.as_deref()
.or_else(|| current.get("display_name").and_then(Value::as_str));
let name = current.get("name").and_then(Value::as_str);
let picture = avatar_url
.as_deref()
.or_else(|| current.get("picture").and_then(Value::as_str));
let ab = about
.as_deref()
.or_else(|| current.get("about").and_then(Value::as_str));
let nip05 = nip05_handle
.as_deref()
.or_else(|| current.get("nip05").and_then(Value::as_str));

let builder = events::build_profile(dn, name, picture, ab, nip05)?;
let current = profile_metadata_from_prior(prior_events.first())?;
let builder = events::build_patched_profile(
current,
events::ProfileMetadataPatch {
display_name: display_name.as_deref(),
name: name.as_deref(),
picture: avatar_url.as_deref(),
about: about.as_deref(),
nip05: nip05_handle.as_deref(),
},
)?
.custom_created_at(monotonic_created_at(
prior_events
.first()
.map(|event| event.created_at.as_secs() as i64),
));
submit_event(builder, &state).await?;

// Re-fetch to return canonical profile.
Expand Down Expand Up @@ -123,9 +118,7 @@ pub async fn update_profile_at_relay(
)
.await?;
let prior_event = prior_events.first();
let current: Value = prior_event
.and_then(|event| serde_json::from_str::<Value>(&event.content).ok())
.unwrap_or(Value::Null);
let current = profile_metadata_from_prior(prior_event)?;
let current_avatar_url = current
.get("picture")
.and_then(Value::as_str)
Expand All @@ -148,21 +141,34 @@ pub async fn update_profile_at_relay(
}

fn build_deferred_profile_event(
current: &Value,
current: &serde_json::Map<String, Value>,
avatar_url: &str,
prior_event: Option<&nostr::Event>,
) -> Result<nostr::EventBuilder, String> {
let display_name = current.get("display_name").and_then(Value::as_str);
let name = current.get("name").and_then(Value::as_str);
let about = current.get("about").and_then(Value::as_str);
let nip05 = current.get("nip05").and_then(Value::as_str);

Ok(
events::build_profile(display_name, name, Some(avatar_url), about, nip05)?
.custom_created_at(monotonic_created_at(
prior_event.map(|event| event.created_at.as_secs() as i64),
)),
)
Ok(events::build_patched_profile(
current.clone(),
events::ProfileMetadataPatch {
picture: Some(avatar_url),
..Default::default()
},
)?
.custom_created_at(monotonic_created_at(
prior_event.map(|event| event.created_at.as_secs() as i64),
)))
}

fn profile_metadata_from_prior(
prior_event: Option<&nostr::Event>,
) -> Result<serde_json::Map<String, Value>, String> {
let Some(prior_event) = prior_event else {
return Ok(serde_json::Map::new());
};
let value: Value = serde_json::from_str(&prior_event.content)
.map_err(|error| format!("existing kind:0 content is not valid JSON: {error}"))?;
value
.as_object()
.cloned()
.ok_or_else(|| "existing kind:0 content must be a JSON object".to_string())
}

fn capture_expected_signer(state: &AppState, expected_pubkey: &str) -> Result<nostr::Keys, String> {
Expand Down Expand Up @@ -405,6 +411,7 @@ fn current_pubkey_hex_unwrap(state: &AppState) -> String {
fn empty_profile_info(pubkey: &str) -> ProfileInfo {
ProfileInfo {
pubkey: pubkey.to_string(),
name: None,
display_name: None,
avatar_url: None,
about: None,
Expand Down Expand Up @@ -452,7 +459,9 @@ mod tests {
.expect("sign prior profile");

let builder = build_deferred_profile_event(
&serde_json::json!({"display_name": "Larry"}),
serde_json::json!({"display_name": "Larry"})
.as_object()
.expect("metadata object"),
"https://example.com/avatar.png",
Some(&prior_event),
)
Expand All @@ -468,6 +477,101 @@ mod tests {
);
}

#[test]
fn profile_patch_preserves_unknown_fields_and_applies_name_semantics() {
let keys = nostr::Keys::generate();
let current = serde_json::json!({
"display_name": "Vitor Cepeda Lopes",
"name": "old",
"website": "https://vitorcepedalopes.com",
"banner": "https://example.com/banner.png",
"custom": {"nested": true}
})
.as_object()
.expect("metadata object")
.clone();

let event = events::build_patched_profile(
current,
events::ProfileMetadataPatch {
name: Some(" theangrypit "),
..Default::default()
},
)
.expect("build patched profile")
.sign_with_keys(&keys)
.expect("sign patched profile");
let content: Value = serde_json::from_str(&event.content).expect("profile JSON");

assert_eq!(content["name"], "theangrypit");
assert_eq!(content["display_name"], "Vitor Cepeda Lopes");
assert_eq!(content["website"], "https://vitorcepedalopes.com");
assert_eq!(content["banner"], "https://example.com/banner.png");
assert_eq!(content["custom"]["nested"], true);

let event = events::build_patched_profile(
content.as_object().expect("metadata object").clone(),
events::ProfileMetadataPatch {
name: Some(" "),
..Default::default()
},
)
.expect("build profile without name")
.sign_with_keys(&keys)
.expect("sign profile without name");
let content: Value = serde_json::from_str(&event.content).expect("profile JSON");

assert!(content.get("name").is_none());
assert_eq!(content["website"], "https://vitorcepedalopes.com");
}

#[test]
fn deferred_avatar_patch_preserves_unknown_profile_fields() {
let keys = nostr::Keys::generate();
let current = serde_json::json!({
"display_name": "Vitor Cepeda Lopes",
"name": "theangrypit",
"website": "https://vitorcepedalopes.com",
"banner": "https://example.com/banner.png"
});
let event = build_deferred_profile_event(
current.as_object().expect("metadata object"),
"https://example.com/new-avatar.png",
None,
)
.expect("build deferred profile")
.sign_with_keys(&keys)
.expect("sign deferred profile");
let content: Value = serde_json::from_str(&event.content).expect("profile JSON");

assert_eq!(content["picture"], "https://example.com/new-avatar.png");
assert_eq!(content["name"], "theangrypit");
assert_eq!(content["website"], "https://vitorcepedalopes.com");
assert_eq!(content["banner"], "https://example.com/banner.png");
}

#[test]
fn invalid_or_non_object_prior_profile_fails_closed() {
let keys = nostr::Keys::generate();
let invalid = nostr::EventBuilder::new(nostr::Kind::Metadata, "not-json")
.sign_with_keys(&keys)
.expect("sign invalid profile");
let array = nostr::EventBuilder::new(nostr::Kind::Metadata, "[]")
.sign_with_keys(&keys)
.expect("sign array profile");

assert!(profile_metadata_from_prior(Some(&invalid))
.unwrap_err()
.starts_with("existing kind:0 content is not valid JSON:"));
assert_eq!(
profile_metadata_from_prior(Some(&array)).unwrap_err(),
"existing kind:0 content must be a JSON object"
);
assert!(profile_metadata_from_prior(None)
.expect("missing profile starts empty")
.is_empty());
}

#[test]
fn user_search_filter_requests_prefix_mode_for_typeahead() {
// Every caller of `search_users` is a typeahead surface. Whole-word
Expand Down
33 changes: 3 additions & 30 deletions desktop/src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST};
use nostr::{EventBuilder, EventId, Kind, Tag};
use uuid::Uuid;

mod profile;
pub use profile::{build_patched_profile, build_profile, ProfileMetadataPatch};

// ── Constants ────────────────────────────────────────────────────────────────

/// Maximum content size — matches buzz-sdk (64 KiB).
Expand Down Expand Up @@ -468,36 +471,6 @@ pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result<EventBuilder,
Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags))
}

// ── Profile ──────────────────────────────────────────────────────────────────

/// Kind 0 — NIP-01 profile metadata (full snapshot).
pub fn build_profile(
display_name: Option<&str>,
name: Option<&str>,
picture: Option<&str>,
about: Option<&str>,
nip05: Option<&str>,
) -> Result<EventBuilder, String> {
let mut map = serde_json::Map::new();
if let Some(v) = display_name {
map.insert("display_name".into(), serde_json::Value::String(v.into()));
}
if let Some(v) = name {
map.insert("name".into(), serde_json::Value::String(v.into()));
}
if let Some(v) = picture {
map.insert("picture".into(), serde_json::Value::String(v.into()));
}
if let Some(v) = about {
map.insert("about".into(), serde_json::Value::String(v.into()));
}
if let Some(v) = nip05 {
map.insert("nip05".into(), serde_json::Value::String(v.into()));
}
let content = serde_json::Value::Object(map).to_string();
Ok(EventBuilder::new(Kind::Custom(0), content))
}

// ── Huddles ──────────────────────────────────────────────────────────────────

/// Validate that a string is a valid UUID (defense-in-depth for `&str` channel IDs).
Expand Down
76 changes: 76 additions & 0 deletions desktop/src-tauri/src/events/profile.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
use nostr::{EventBuilder, Kind};

/// Kind 0 — NIP-01 profile metadata (full snapshot).
pub fn build_profile(
display_name: Option<&str>,
name: Option<&str>,
picture: Option<&str>,
about: Option<&str>,
nip05: Option<&str>,
) -> Result<EventBuilder, String> {
let mut map = serde_json::Map::new();
if let Some(v) = display_name {
map.insert("display_name".into(), serde_json::Value::String(v.into()));
}
if let Some(v) = name {
map.insert("name".into(), serde_json::Value::String(v.into()));
}
if let Some(v) = picture {
map.insert("picture".into(), serde_json::Value::String(v.into()));
}
if let Some(v) = about {
map.insert("about".into(), serde_json::Value::String(v.into()));
}
if let Some(v) = nip05 {
map.insert("nip05".into(), serde_json::Value::String(v.into()));
}
let content = serde_json::Value::Object(map).to_string();
Ok(EventBuilder::new(Kind::Custom(0), content))
}

/// Partial update for an existing kind:0 metadata object.
///
/// Kind:0 events are replaceable full snapshots, so callers must start from
/// the complete prior object and patch only fields explicitly supplied by the
/// user. `None` preserves a field, a non-empty string sets its trimmed value,
/// and an empty/whitespace-only string removes it.
#[derive(Default)]
pub struct ProfileMetadataPatch<'a> {
pub display_name: Option<&'a str>,
pub name: Option<&'a str>,
pub picture: Option<&'a str>,
pub about: Option<&'a str>,
pub nip05: Option<&'a str>,
}

pub fn build_patched_profile(
mut metadata: serde_json::Map<String, serde_json::Value>,
patch: ProfileMetadataPatch<'_>,
) -> Result<EventBuilder, String> {
patch_profile_field(&mut metadata, "display_name", patch.display_name);
patch_profile_field(&mut metadata, "name", patch.name);
patch_profile_field(&mut metadata, "picture", patch.picture);
patch_profile_field(&mut metadata, "about", patch.about);
patch_profile_field(&mut metadata, "nip05", patch.nip05);

Ok(EventBuilder::new(
Kind::Custom(0),
serde_json::Value::Object(metadata).to_string(),
))
}

fn patch_profile_field(
metadata: &mut serde_json::Map<String, serde_json::Value>,
field: &str,
value: Option<&str>,
) {
let Some(value) = value else {
return;
};
let value = value.trim();
if value.is_empty() {
metadata.remove(field);
} else {
metadata.insert(field.to_string(), serde_json::Value::String(value.into()));
}
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub struct IdentityInfo {
#[derive(Serialize, Deserialize)]
pub struct ProfileInfo {
pub pubkey: String,
pub name: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub about: Option<String>,
Expand Down
Loading