diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 59c80c4807..efd6882985 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -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), ), ); diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index ef67fac570..02bac81c53 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -39,6 +39,7 @@ pub async fn get_profile(state: State<'_, AppState>) -> Result, + name: Option, avatar_url: Option, about: Option, nip05_handle: Option, @@ -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::(&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. @@ -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::(&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) @@ -148,21 +141,34 @@ pub async fn update_profile_at_relay( } fn build_deferred_profile_event( - current: &Value, + current: &serde_json::Map, avatar_url: &str, prior_event: Option<&nostr::Event>, ) -> Result { - 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, 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 { @@ -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, @@ -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), ) @@ -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 diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02e..88eaf093fa 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -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). @@ -468,36 +471,6 @@ pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result, - name: Option<&str>, - picture: Option<&str>, - about: Option<&str>, - nip05: Option<&str>, -) -> Result { - 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). diff --git a/desktop/src-tauri/src/events/profile.rs b/desktop/src-tauri/src/events/profile.rs new file mode 100644 index 0000000000..aacb385c7d --- /dev/null +++ b/desktop/src-tauri/src/events/profile.rs @@ -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 { + 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, + patch: ProfileMetadataPatch<'_>, +) -> Result { + 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, + 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())); + } +} diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 1d9747bc20..304ae7b497 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -27,6 +27,7 @@ pub struct IdentityInfo { #[derive(Serialize, Deserialize)] pub struct ProfileInfo { pub pubkey: String, + pub name: Option, pub display_name: Option, pub avatar_url: Option, pub about: Option, diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index ec4970e0c9..fded6ef5c0 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -288,21 +288,16 @@ pub fn profile_info_from_event(event: &Event) -> Result { let v: Value = serde_json::from_str(&event.content) .map_err(|e| format!("kind:0 content is not valid JSON: {e}"))?; - let display_name = v - .get("display_name") - .and_then(Value::as_str) - .or_else(|| v.get("name").and_then(Value::as_str)) - .map(str::to_string); - let avatar_url = v.get("picture").and_then(Value::as_str).map(str::to_string); - let about = v.get("about").and_then(Value::as_str).map(str::to_string); - let nip05_handle = v.get("nip05").and_then(Value::as_str).map(str::to_string); - Ok(ProfileInfo { pubkey: event.pubkey.to_hex(), - display_name, - avatar_url, - about, - nip05_handle, + name: v.get("name").and_then(Value::as_str).map(str::to_string), + display_name: v + .get("display_name") + .and_then(Value::as_str) + .map(str::to_string), + avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), + about: v.get("about").and_then(Value::as_str).map(str::to_string), + nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), owner_pubkey: profile_valid_oa_owner_pubkey(event), has_profile_event: true, }) @@ -333,13 +328,17 @@ pub fn users_batch_from_events( for (pk, ev) in &latest { let v: Value = serde_json::from_str(&ev.content).unwrap_or(Value::Null); let owner_pubkey = profile_valid_oa_owner_pubkey(ev); - let summary = UserProfileSummaryInfo { - display_name: v - .get("display_name") + let nonblank = |key| { + v.get(key) .and_then(Value::as_str) - .or_else(|| v.get("name").and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + }; + let summary = UserProfileSummaryInfo { + display_name: nonblank("display_name") + .or_else(|| nonblank("name")) .map(str::to_string), - name: v.get("name").and_then(Value::as_str).map(str::to_string), + name: nonblank("name").map(str::to_string), avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), is_agent: owner_pubkey.is_some(), @@ -783,10 +782,10 @@ mod tests { } #[test] - fn profile_info_falls_back_to_name() { - let e = ev(0, r#"{"name":"bob"}"#, vec![]); - let p = profile_info_from_event(&e).unwrap(); - assert_eq!(p.display_name.as_deref(), Some("bob")); + fn profile_info_keeps_name_and_display_name_independent() { + let p = profile_info_from_event(&ev(0, r#"{"name":"bob"}"#, vec![])).unwrap(); + assert_eq!(p.name.as_deref(), Some("bob")); + assert_eq!(p.display_name, None); } #[test] @@ -804,7 +803,8 @@ mod tests { .custom_created_at(nostr::Timestamp::from(1000)) .sign_with_keys(&keys) .unwrap(); - let e_new = EventBuilder::new(Kind::Metadata, r#"{"display_name":"New"}"#) + let e_new = + EventBuilder::new(Kind::Metadata, r#"{"display_name":" ","name":" New "}"#) .custom_created_at(nostr::Timestamp::from(2000)) .sign_with_keys(&keys) .unwrap(); diff --git a/desktop/src/features/channels/ui/agentSessionSelection.test.mjs b/desktop/src/features/channels/ui/agentSessionSelection.test.mjs index af3db4e53e..2af970e6bf 100644 --- a/desktop/src/features/channels/ui/agentSessionSelection.test.mjs +++ b/desktop/src/features/channels/ui/agentSessionSelection.test.mjs @@ -1,7 +1,31 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { resolveAgentSessionReturnTarget } from "./agentSessionSelection.ts"; +import { + resolveAgentSessionReturnTarget, + resolveSelectedAgentSession, +} from "./agentSessionSelection.ts"; + +test("uses a name-only Nostr profile for a fallback agent session", () => { + const pubkey = "a".repeat(64); + + assert.equal( + resolveSelectedAgentSession({ + agentSessionAgents: [], + openAgentSessionPubkey: pubkey, + profilePanelPubkey: pubkey, + profiles: { + [pubkey]: { + avatarUrl: null, + displayName: null, + name: "Bumble", + pubkey, + }, + }, + })?.name, + "Bumble", + ); +}); test("returns the open thread when activity opens over a thread", () => { assert.deepEqual( diff --git a/desktop/src/features/channels/ui/agentSessionSelection.ts b/desktop/src/features/channels/ui/agentSessionSelection.ts index fa1a063a96..036a77173a 100644 --- a/desktop/src/features/channels/ui/agentSessionSelection.ts +++ b/desktop/src/features/channels/ui/agentSessionSelection.ts @@ -35,7 +35,7 @@ export function resolveSelectedAgentSession({ const profile = profiles?.[openAgentSessionPubkey.toLowerCase()]; return { pubkey: openAgentSessionPubkey, - name: profile?.displayName?.trim() || "Agent", + name: profile?.displayName?.trim() || profile?.name?.trim() || "Agent", status: "deployed", agentSource: "relay", canInterruptTurn: false, diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index 35d660e475..20ae68c48b 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -344,6 +344,7 @@ export function HuddleBar({ () => clampReactionName( profileQuery.data?.displayName?.trim() || + profileQuery.data?.name?.trim() || identityQuery.data?.displayName?.trim() || fallbackNameForPubkey(currentPubkey), ), @@ -351,6 +352,7 @@ export function HuddleBar({ currentPubkey, identityQuery.data?.displayName, profileQuery.data?.displayName, + profileQuery.data?.name, ], ); diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs index 926738a60b..530312a46f 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs +++ b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs @@ -416,6 +416,50 @@ test("reaction pills sort by earliest created_at ascending", () => { ); }); +test("reaction actor labels fall back from blank display_name to name", () => { + const MSG_ID = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + const message = { + id: MSG_ID, + pubkey: PUBKEY_A, + kind: 9, + created_at: 1_700_000_000, + content: "hello", + tags: [["h", CHANNEL_ID]], + sig: "sig", + }; + const reaction = { + id: `d${"d".repeat(63)}`, + pubkey: PUBKEY_B, + kind: 7, + created_at: 1_700_001_001, + content: "🎉", + tags: [ + ["h", CHANNEL_ID], + ["e", MSG_ID], + ], + sig: "sig", + }; + + const output = formatTimelineMessages( + [message, reaction], + null, + undefined, + null, + { + [PUBKEY_B]: { + avatarUrl: null, + displayName: " ", + name: "alice", + nip05Handle: null, + ownerPubkey: null, + }, + }, + ); + + assert.equal(output[0].reactions?.[0]?.users[0]?.displayName, "alice"); +}); + test("reaction pills with equal created_at tiebreak deterministically on emoji string", () => { const MSG_ID = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 640c12bb75..7539d5523b 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -340,6 +340,7 @@ export function formatTimelineMessages( currentPubkeyLower && actorPubkey === currentPubkeyLower ? "You" : profile?.displayName?.trim() || + profile?.name?.trim() || profile?.nip05Handle?.trim() || truncatePubkey(actorPubkey); existing.users.push({ diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 4b729ab33f..229cb5f17f 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -152,7 +152,10 @@ export function CommunityOnboardingFlow({ const queryClient = useQueryClient(); const systemColorScheme = useSystemColorScheme(); const [displayName, setDisplayName] = React.useState(""); + const [name, setName] = React.useState(""); + const nameWasEditedRef = React.useRef(false); const [avatarUrl, setAvatarUrl] = React.useState(""); + const avatarWasEditedRef = React.useRef(false); const avatarPresentation = useAvatarPresentation(avatarUrl); const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false); const [isAvatarEditorOpen, setIsAvatarEditorOpen] = React.useState(false); @@ -288,10 +291,9 @@ export function CommunityOnboardingFlow({ transaction?.stage === "finalizing" || transaction?.stage === "entering"; - // Seed display name and avatar from the relay profile when the profile step - // is shown. This covers the case where the skip raced or was bypassed (e.g., - // the user navigated Back). Only seeds fields that are still empty so that - // any user edits are preserved. + // Seed profile fields from the relay when this step is shown. This covers + // cases where the skip raced or was bypassed (for example, after navigating + // Back). Only untouched fields are seeded. React.useEffect(() => { if (!isProfileStage) return; void getProfile() @@ -301,7 +303,10 @@ export function CommunityOnboardingFlow({ prev === "" ? (profile.displayName ?? "") : prev, ); } - if (profile.avatarUrl) { + if (!nameWasEditedRef.current && profile.name) { + setName((prev) => (prev === "" ? (profile.name ?? "") : prev)); + } + if (!avatarWasEditedRef.current && profile.avatarUrl) { setAvatarUrl((prev) => prev === "" ? (profile.avatarUrl ?? "") : prev, ); @@ -385,12 +390,18 @@ export function CommunityOnboardingFlow({ const candidateAvatarUrl = avatarUrl.trim(); const presentationState = avatarPresentation?.state; const shouldSaveCandidate = + avatarWasEditedRef.current && candidateAvatarUrl.length > 0 && presentationState !== "failed" && presentationState !== "pending"; + const shouldClearAvatar = + avatarWasEditedRef.current && candidateAvatarUrl.length === 0; const deferredAvatar = - candidateAvatarUrl && presentationState && presentationState !== "ready" + avatarWasEditedRef.current && + candidateAvatarUrl && + presentationState && + presentationState !== "ready" ? registerAvatarWhenReady({ avatarUrl: candidateAvatarUrl, relayUrl: transaction.relayUrl, @@ -400,7 +411,12 @@ export function CommunityOnboardingFlow({ try { const profile = await updateProfile({ displayName: displayName.trim(), - avatarUrl: shouldSaveCandidate ? candidateAvatarUrl : undefined, + ...(nameWasEditedRef.current ? { name: name.trim() } : {}), + avatarUrl: shouldSaveCandidate + ? candidateAvatarUrl + : shouldClearAvatar + ? "" + : undefined, }); deferredAvatar?.release({ expectedPubkey: profile.pubkey, @@ -503,8 +519,8 @@ export function CommunityOnboardingFlow({

Build your profile

- Add a name and avatar. They’ll show up on your messages, - reactions, and agent handoffs. + Add a display name, Nostr handle, and avatar. They’ll show + up on your messages, reactions, and agent handoffs.

@@ -519,25 +535,54 @@ export function CommunityOnboardingFlow({ htmlFor="community-display-name" > - Your username + Display name setDisplayName(event.target.value)} - placeholder="Enter your username here" + placeholder="Enter your display name" ref={nameInputRef} spellCheck={false} type="text" value={displayName} /> +
{transaction.error ? (

@@ -606,7 +651,10 @@ export function CommunityOnboardingFlow({ emojiPickerThemeVars={NEUTRAL_EMOJI_PICKER_THEME_VARS} onDone={() => setIsAvatarEditorOpen(false)} onUploadingChange={setIsUploadingAvatar} - onUrlChange={setAvatarUrl} + onUrlChange={(nextAvatarUrl) => { + avatarWasEditedRef.current = true; + setAvatarUrl(nextAvatarUrl); + }} presentation="onboarding-modal" previewName={displayName.trim() || "Your profile"} testIdPrefix="community-avatar" diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx index 01a226e3de..ef4fbe957b 100644 --- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx @@ -105,6 +105,7 @@ function resolveSavedProfile({ return { avatarUrl: profile?.avatarUrl ?? "", displayName: sanitizeDisplayName(profile?.displayName), + name: profile?.name ?? "", }; } @@ -116,10 +117,12 @@ function createProfileUpdatePayload({ savedProfile: OnboardingProfileValues; }) { const nextDisplayName = draftProfile.displayName.trim(); + const nextName = draftProfile.name.trim(); const nextAvatarUrl = draftProfile.avatarUrl.trim(); const updatePayload: { avatarUrl?: string; displayName?: string; + name?: string; } = {}; if ( @@ -129,6 +132,10 @@ function createProfileUpdatePayload({ updatePayload.displayName = nextDisplayName; } + if (nextName !== savedProfile.name) { + updatePayload.name = nextName; + } + if (nextAvatarUrl.length > 0 && nextAvatarUrl !== savedProfile.avatarUrl) { updatePayload.avatarUrl = nextAvatarUrl; } @@ -324,6 +331,13 @@ export function OnboardingFlow({ [updateProfileDraft], ); + const updateNameDraft = React.useCallback( + (value: string) => { + updateProfileDraft({ name: value }); + }, + [updateProfileDraft], + ); + const updateAvatarUrlDraft = React.useCallback( (value: string) => { updateProfileDraft({ avatarUrl: value }); @@ -340,9 +354,15 @@ export function OnboardingFlow({ setProfileDraft((current) => ({ ...current, displayName: savedProfile.displayName, + name: savedProfile.name, })); showAvatarPage(); - }, [profileUpdateMutation, savedProfile.displayName, showAvatarPage]); + }, [ + profileUpdateMutation, + savedProfile.displayName, + savedProfile.name, + showAvatarPage, + ]); const saveErrorMessage = profileSaveError instanceof Error ? profileSaveError.message : null; @@ -351,6 +371,10 @@ export function OnboardingFlow({ draftUrl: profileDraft.avatarUrl, savedUrl: savedProfile.avatarUrl, }, + handle: { + draftValue: profileDraft.name, + savedValue: savedProfile.name, + }, isUploadingAvatar, isSaving: isSavingProfile || isProfileAdvancePending, name: { @@ -514,6 +538,7 @@ export function OnboardingFlow({ }, updateAvatarUrl: updateAvatarUrlDraft, updateDisplayName: updateDisplayNameDraft, + updateName: updateNameDraft, }} direction={transitionDirection} state={profileStepState} diff --git a/desktop/src/features/onboarding/ui/ProfileStep.tsx b/desktop/src/features/onboarding/ui/ProfileStep.tsx index 69da9b8f7c..f314a7e51c 100644 --- a/desktop/src/features/onboarding/ui/ProfileStep.tsx +++ b/desktop/src/features/onboarding/ui/ProfileStep.tsx @@ -7,6 +7,7 @@ import { useReconnectRelay } from "@/shared/api/useReconnectRelay"; import { cn } from "@/shared/lib/cn"; import { isRelayUnreachableError } from "@/shared/lib/relayError"; import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; import { Spinner } from "@/shared/ui/spinner"; import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; import { OnboardingFooter } from "./OnboardingFooter"; @@ -200,9 +201,11 @@ export function ProfileStep({ skipForNow, submit, updateDisplayName, + updateName, } = actions; - const { isSaving, name, saveRecovery } = state; + const { handle, isSaving, name, saveRecovery } = state; const displayNameDraft = name.draftValue; + const handleDraft = handle.draftValue; const hasDisplayNameDraft = displayNameDraft.length > 0; const canSubmit = displayNameDraft.trim().length > 0 && !isSaving; const inputRef = React.useRef(null); @@ -275,6 +278,39 @@ export function ProfileStep({ +

+ A short name for your Nostr profile. It does not need to be unique. +

+ updateName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && canSubmit) { + event.preventDefault(); + submit(); + } + }} + placeholder="e.g. theangrypit" + spellCheck={false} + value={handleDraft} + /> + + {saveRecovery.errorMessage ? ( ) : null} diff --git a/desktop/src/features/onboarding/ui/types.ts b/desktop/src/features/onboarding/ui/types.ts index 443a1f3f4e..fe74cb9e37 100644 --- a/desktop/src/features/onboarding/ui/types.ts +++ b/desktop/src/features/onboarding/ui/types.ts @@ -18,6 +18,7 @@ export type OnboardingProfileSeed = { export type OnboardingProfileValues = { avatarUrl: string; displayName: string; + name: string; }; export type ProfileStepSaveRecovery = { @@ -38,6 +39,7 @@ export type ProfileStepAvatarState = { export type ProfileStepState = { avatar: ProfileStepAvatarState; + handle: ProfileStepNameState; isUploadingAvatar: boolean; isSaving: boolean; name: ProfileStepNameState; @@ -54,6 +56,7 @@ export type ProfileStepActions = { submit: () => void; updateAvatarUrl: (value: string) => void; updateDisplayName: (value: string) => void; + updateName: (value: string) => void; }; export type SetupStepActions = { diff --git a/desktop/src/features/onboarding/welcomeKickoff.ts b/desktop/src/features/onboarding/welcomeKickoff.ts index 57352b5294..1cab78b577 100644 --- a/desktop/src/features/onboarding/welcomeKickoff.ts +++ b/desktop/src/features/onboarding/welcomeKickoff.ts @@ -667,7 +667,8 @@ export function useWelcomeKickoff( const owner = await getProfile() .then((profile) => ({ pubkey: profile.pubkey, - displayName: profile.displayName, + displayName: + profile.displayName?.trim() || profile.name?.trim() || null, })) .catch(() => null); const openerResult = await sendManagedAgentChannelMessage( diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 04546804eb..5e334ff0be 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -73,6 +73,7 @@ async function persistSelfProfile( writeSelfProfileCache(relayUrl, pubkey, { version: 1, displayName: profile.displayName, + name: profile.name, avatarUrl: profile.avatarUrl, about: profile.about, avatarDataUrl, @@ -107,6 +108,7 @@ export function useProfileQuery(enabled = true) { ? ({ pubkey, displayName: cached.displayName, + name: cached.name, avatarUrl: cached.avatarUrl, about: cached.about, nip05Handle: null, @@ -388,6 +390,7 @@ export function useUsersBatchQuery( // These cached summaries are never used for the onboarding gate. hasProfileEvent: false, ...summary, + name: summary.name ?? null, }, ); } @@ -515,7 +518,7 @@ export function useUpdateProfileMutation() { await updateCachedChannelMemberDisplayName( queryClient, pubkey, - profile.displayName, + profile.displayName?.trim() || profile.name?.trim() || null, ); // Own author labels/avatars render through the users-batch delta diff --git a/desktop/src/features/profile/lib/identity.test.mjs b/desktop/src/features/profile/lib/identity.test.mjs index da0259a66f..5f78b45f8a 100644 --- a/desktop/src/features/profile/lib/identity.test.mjs +++ b/desktop/src/features/profile/lib/identity.test.mjs @@ -1,13 +1,19 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { formatOwnerLabel, profileLookupsEqual } from "./identity.ts"; +import { + formatOwnerLabel, + mergeCurrentProfileIntoLookup, + profileLookupsEqual, + resolveUserLabel, +} from "./identity.ts"; const OWNER_PUBKEY = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const summary = (over = {}) => ({ displayName: "Ada", + name: null, avatarUrl: "https://x/a.png", nip05Handle: "ada@x", ownerPubkey: null, @@ -18,12 +24,29 @@ const summary = (over = {}) => ({ test("formatOwnerLabel resolves a known owner's display name", () => { assert.equal( formatOwnerLabel(OWNER_PUBKEY, null, { - [OWNER_PUBKEY]: summary({ displayName: "baxen" }), + [OWNER_PUBKEY]: summary({ + displayName: "baxen", + nip05Handle: null, + }), }), "baxen", ); }); +test("formatOwnerLabel prefers NIP-05 when all owner identity fields coexist", () => { + assert.equal( + formatOwnerLabel(OWNER_PUBKEY, null, { + [OWNER_PUBKEY]: { + pubkey: OWNER_PUBKEY, + name: "legacy-name", + displayName: "Human Name", + nip05Handle: "owner@example.com", + }, + }), + "owner@example.com", + ); +}); + test("formatOwnerLabel calls the viewer-owned agent's owner you", () => { assert.equal(formatOwnerLabel(OWNER_PUBKEY, OWNER_PUBKEY, {}), "you"); }); @@ -58,6 +81,7 @@ test("profileLookupsEqual: same count, different keys is not equal", () => { test("profileLookupsEqual: a changed field is not equal", () => { for (const field of [ "displayName", + "name", "avatarUrl", "nip05Handle", "ownerPubkey", @@ -78,6 +102,72 @@ test("profileLookupsEqual: two empty lookups are equal", () => { assert.equal(profileLookupsEqual({}, {}), true); }); +test("resolveUserLabel uses a legacy kind-0 name when display_name is absent", () => { + assert.equal( + resolveUserLabel({ + pubkey: OWNER_PUBKEY, + profiles: { + [OWNER_PUBKEY]: summary({ + displayName: null, + name: "legacy-name", + nip05Handle: null, + }), + }, + }), + "legacy-name", + ); +}); + +test("mergeCurrentProfileIntoLookup uses a nonblank name as its presentation label", () => { + const profiles = mergeCurrentProfileIntoLookup( + { + [OWNER_PUBKEY]: summary({ + displayName: "legacy-name", + name: "legacy-name", + }), + }, + { + pubkey: OWNER_PUBKEY, + name: "legacy-name", + displayName: " ", + avatarUrl: null, + nip05Handle: null, + }, + ); + + assert.equal(profiles?.[OWNER_PUBKEY]?.displayName, "legacy-name"); + assert.equal(profiles?.[OWNER_PUBKEY]?.name, "legacy-name"); + assert.equal( + resolveUserLabel({ pubkey: OWNER_PUBKEY, profiles }), + "legacy-name", + ); +}); + +test("mergeCurrentProfileIntoLookup does not restore a cleared name from stale lookup data", () => { + const profiles = mergeCurrentProfileIntoLookup( + { + [OWNER_PUBKEY]: summary({ + displayName: "legacy-name", + name: "legacy-name", + }), + }, + { + pubkey: OWNER_PUBKEY, + name: null, + displayName: null, + avatarUrl: null, + nip05Handle: null, + }, + ); + + assert.equal(profiles?.[OWNER_PUBKEY]?.displayName, null); + assert.equal(profiles?.[OWNER_PUBKEY]?.name, null); + assert.notEqual( + resolveUserLabel({ pubkey: OWNER_PUBKEY, profiles }), + "legacy-name", + ); +}); + // Render-count proof for the Tier-1 typing-storm fix (#1533 discipline). // MessageRow re-renders iff `prev.profiles === next.profiles` fails, so the // stabiliser's job is: hold the reference across value-equal re-derives (the diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index d2e0a4fdd3..eae0803c29 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -64,7 +64,10 @@ function getResolvedProfile( export function mergeCurrentProfileIntoLookup( profiles: UserProfileLookup | undefined, currentProfile: - | Pick + | Pick< + Profile, + "pubkey" | "name" | "displayName" | "avatarUrl" | "nip05Handle" + > | null | undefined, ) { @@ -75,10 +78,11 @@ export function mergeCurrentProfileIntoLookup( return { ...(profiles ?? {}), [normalizePubkey(currentProfile.pubkey)]: { - displayName: currentProfile.displayName, - // `Profile` does not carry the kind-0 `name`; keep whatever the batch - // lookup already resolved so mention aliases survive the merge. - name: profiles?.[normalizePubkey(currentProfile.pubkey)]?.name ?? null, + displayName: + currentProfile.displayName?.trim() || + currentProfile.name?.trim() || + null, + name: currentProfile.name, avatarUrl: currentProfile.avatarUrl, nip05Handle: currentProfile.nip05Handle, isAgent: profiles?.[normalizePubkey(currentProfile.pubkey)]?.isAgent, @@ -118,6 +122,11 @@ export function resolveUserLabel(input: { return displayName; } + const name = profile?.name?.trim(); + if (name) { + return name; + } + const nip05Handle = profile?.nip05Handle?.trim(); if (nip05Handle) { return nip05Handle; @@ -154,9 +163,10 @@ export function resolveUserSecondaryLabel(input: { }) { const profile = getResolvedProfile(input.pubkey, input.profiles); const displayName = profile?.displayName?.trim(); + const name = profile?.name?.trim(); const nip05Handle = profile?.nip05Handle?.trim(); - if (displayName && nip05Handle) { + if ((displayName || name) && nip05Handle) { return nip05Handle; } @@ -165,7 +175,7 @@ export function resolveUserSecondaryLabel(input: { /** * Label for an agent's owner: "you" when the current user owns it, otherwise - * the owner's display name, NIP-05 handle, or truncated pubkey. + * the owner's NIP-05 handle, display name, Nostr name, or truncated pubkey. */ export function formatOwnerLabel( ownerPubkey: string | null | undefined, @@ -186,8 +196,9 @@ export function formatOwnerLabel( const owner = ownerProfiles?.[normalizedOwnerPubkey]; return ( - owner?.displayName?.trim() || owner?.nip05Handle?.trim() || + owner?.displayName?.trim() || + owner?.name?.trim() || truncatePubkey(ownerPubkey) ); } diff --git a/desktop/src/features/profile/lib/profileActivityAgent.test.mjs b/desktop/src/features/profile/lib/profileActivityAgent.test.mjs new file mode 100644 index 0000000000..1dcc351810 --- /dev/null +++ b/desktop/src/features/profile/lib/profileActivityAgent.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveProfileActivityAgent } from "./profileActivityAgent.ts"; + +test("uses a name-only Nostr profile for agent activity", () => { + const agent = resolveProfileActivityAgent({ + effectivePubkey: "a".repeat(64), + isBot: true, + managedAgent: undefined, + profile: { + avatarUrl: null, + displayName: null, + name: "Bumble", + }, + relayAgent: undefined, + viewerIsOwner: true, + }); + + assert.equal(agent?.name, "Bumble"); +}); diff --git a/desktop/src/features/profile/lib/profileActivityAgent.ts b/desktop/src/features/profile/lib/profileActivityAgent.ts index 2bc4fe4465..2b0275a651 100644 --- a/desktop/src/features/profile/lib/profileActivityAgent.ts +++ b/desktop/src/features/profile/lib/profileActivityAgent.ts @@ -18,7 +18,11 @@ export function resolveProfileActivityAgent({ effectivePubkey: string | null; isBot: boolean; managedAgent: ManagedAgent | undefined; - profile: { avatarUrl?: string | null; displayName?: string | null } | null; + profile: { + avatarUrl?: string | null; + displayName?: string | null; + name?: string | null; + } | null; relayAgent: RelayAgent | undefined; viewerIsOwner: boolean; }): ProfileActivityAgent | null { @@ -37,7 +41,9 @@ export function resolveProfileActivityAgent({ return { avatarUrl: profile?.avatarUrl ?? null, - name: relayAgent?.name ?? profile?.displayName?.trim() ?? "Agent", + name: + relayAgent?.name ?? + (profile?.displayName?.trim() || profile?.name?.trim() || "Agent"), pubkey: effectivePubkey, status: relayAgent?.status === "offline" ? "stopped" : "deployed", }; diff --git a/desktop/src/features/profile/lib/selfProfileStorage.test.mjs b/desktop/src/features/profile/lib/selfProfileStorage.test.mjs index 6df7a5f976..b82f2f22b4 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.test.mjs +++ b/desktop/src/features/profile/lib/selfProfileStorage.test.mjs @@ -52,6 +52,7 @@ test("parseSelfProfileCache: valid v1 payload round-trips", () => { const payload = { version: 1, displayName: "Alice", + name: "alice", avatarUrl: "https://relay.example.com/media/abc.jpg", about: "Building better communities", avatarDataUrl: "data:image/jpeg;base64,/9j/4A==", @@ -65,6 +66,7 @@ test("parseSelfProfileCache: null fields are preserved", () => { const payload = { version: 1, displayName: null, + name: null, avatarUrl: null, about: null, avatarDataUrl: null, @@ -131,6 +133,7 @@ test("parseSelfProfileCache: non-finite updatedAt is coerced to 0", () => { const resultNaN = parseSelfProfileCache({ version: 1, displayName: null, + name: null, avatarUrl: null, avatarDataUrl: null, updatedAt: NaN, @@ -207,6 +210,7 @@ function makeCache(overrides = {}) { return { version: 1, displayName: null, + name: null, avatarUrl: null, avatarDataUrl: null, updatedAt: 0, diff --git a/desktop/src/features/profile/lib/selfProfileStorage.ts b/desktop/src/features/profile/lib/selfProfileStorage.ts index dbc4f88760..47d4c51391 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.ts +++ b/desktop/src/features/profile/lib/selfProfileStorage.ts @@ -34,6 +34,8 @@ export const SELF_PROFILE_CACHE_EVENT = "buzz:self-profile-cache"; export type SelfProfileCache = { version: 1; displayName: string | null; + /** Kind-0 `name`, kept distinct from the human-facing display name. */ + name: string | null; /** Original relay URL from the kind-0 profile event. */ avatarUrl: string | null; about: string | null; @@ -58,6 +60,7 @@ export type SelfProfileCache = { const DEFAULT_CACHE: SelfProfileCache = Object.freeze({ version: 1, displayName: null, + name: null, avatarUrl: null, about: null, avatarDataUrl: null, @@ -86,6 +89,7 @@ export function parseSelfProfileCache(json: unknown): SelfProfileCache | null { const displayName = typeof obj.displayName === "string" ? obj.displayName : null; + const name = typeof obj.name === "string" ? obj.name : null; const avatarUrl = typeof obj.avatarUrl === "string" ? obj.avatarUrl : null; const about = typeof obj.about === "string" ? obj.about : null; // Defense-in-depth: avatarDataUrl flows into an sink; only accept @@ -107,6 +111,7 @@ export function parseSelfProfileCache(json: unknown): SelfProfileCache | null { return { version: 1, displayName, + name, avatarUrl, about, avatarDataUrl, diff --git a/desktop/src/features/profile/profileCacheSync.ts b/desktop/src/features/profile/profileCacheSync.ts index 656096c973..b01a939b9c 100644 --- a/desktop/src/features/profile/profileCacheSync.ts +++ b/desktop/src/features/profile/profileCacheSync.ts @@ -70,6 +70,7 @@ export async function refreshProfileCaches( const baseCache = { version: 1 as const, displayName: profile.displayName, + name: profile.name, avatarUrl: profile.avatarUrl, about: profile.about, avatarDataUrl: resolveAvatarDataUrl(profile.avatarUrl, null, existing), diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index c1f713d230..f4625effda 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -78,9 +78,9 @@ import { type ProfilePanelTab, type ProfilePanelView, resolveAgentInstruction, + resolveOwnerHandle, resolvePanelProfile, resolveProfileDisplayName, - truncatePubkey, type UserProfilePanelProps, useRetainedPersona, } from "@/features/profile/ui/UserProfilePanelUtils"; @@ -671,24 +671,14 @@ export function UserProfilePanel({ }); const ownerHandle = React.useMemo(() => { if (ownerPubkey) { - const ownerProfile = ownerProfileQuery.data; - return ( - ownerProfile?.nip05Handle?.trim() || - ownerProfile?.displayName?.trim() || - truncatePubkey(ownerPubkey) - ); + return resolveOwnerHandle(ownerProfileQuery.data, ownerPubkey); } if (currentPubkey === undefined || isOwner !== true) { return null; } - const currentProfile = currentProfileQuery.data; - return ( - currentProfile?.nip05Handle?.trim() || - currentProfile?.displayName?.trim() || - truncatePubkey(currentPubkey) - ); + return resolveOwnerHandle(currentProfileQuery.data, currentPubkey); }, [ currentProfileQuery.data, currentPubkey, diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index 89837f6017..b981bbed18 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -7,6 +7,8 @@ import { personaManagedAgentUpdate, profilePanelTabFromSearch, profilePanelViewFromSearch, + resolveOwnerHandle, + resolveProfileDisplayName, } from "./UserProfilePanelUtils.ts"; function agent(overrides = {}) { @@ -189,3 +191,41 @@ test("profilePanelTabFromSearch falls back to info for invalid values", () => { assert.equal(profilePanelTabFromSearch("missing"), "info"); assert.equal(profilePanelTabFromSearch(null), "info"); }); + +test("profile labels fall back to the independent Nostr name field", () => { + const profile = { + pubkey: "deadbeef".repeat(8), + name: "legacy-name", + displayName: " ", + avatarUrl: null, + about: null, + nip05Handle: null, + ownerPubkey: null, + hasProfileEvent: true, + }; + + assert.equal( + resolveProfileDisplayName({ + persona: undefined, + profile, + pubkey: profile.pubkey, + }), + "legacy-name", + ); + assert.equal(resolveOwnerHandle(profile, profile.pubkey), "legacy-name"); +}); + +test("owner labels keep display_name ahead of the Nostr name fallback", () => { + const profile = { + pubkey: "deadbeef".repeat(8), + name: "legacy-name", + displayName: "Human Name", + avatarUrl: null, + about: null, + nip05Handle: null, + ownerPubkey: null, + hasProfileEvent: true, + }; + + assert.equal(resolveOwnerHandle(profile, profile.pubkey), "Human Name"); +}); diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 07f57803b4..33260bd4aa 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -160,6 +160,7 @@ export function getRelayAgentChannelIds( export function buildPersonaDraftProfile(persona: AgentPersona): Profile { return { pubkey: "", + name: null, displayName: persona.displayName, avatarUrl: persona.avatarUrl, about: null, @@ -229,8 +230,9 @@ export function resolveProfileDisplayName({ pubkey: string | null; }) { return ( - profile?.displayName ?? - persona?.displayName ?? + profile?.displayName?.trim() || + profile?.name?.trim() || + persona?.displayName?.trim() || (pubkey ? truncatePubkey(pubkey) : "Agent") ); } @@ -246,6 +248,7 @@ export function resolveOwnerHandle( return ( profile?.nip05Handle?.trim() || profile?.displayName?.trim() || + profile?.name?.trim() || truncatePubkey(currentPubkey) ); } diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index f2739088a6..cbdc788bcf 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -234,7 +234,10 @@ export function UserProfilePopover({ relayAgentsQuery.isPending || managedAgentsQuery.isPending || usersBatchQuery.isPending); - const displayName = profile?.displayName ?? truncatePubkey(pubkey); + const displayName = + profile?.displayName?.trim() || + profile?.name?.trim() || + truncatePubkey(pubkey); // Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent // runs elsewhere holds no local seckey, so key custody (`isOwner`) alone // wrongly hides the affordance from them — and gating on bot-ness alone shows @@ -417,6 +420,7 @@ export function UserProfilePopover({ (await openDmMutation.mutateAsync({ pubkeys: [pubkey] })); const senderName = selfProfileQuery.data?.displayName?.trim() || + selfProfileQuery.data?.name?.trim() || identity.displayName.trim() || truncatePubkey(identity.pubkey); const content = buildWaveMessageContent(senderName); @@ -489,6 +493,7 @@ export function UserProfilePopover({ pubkey, queryClient, selfProfileQuery.data?.displayName, + selfProfileQuery.data?.name, showHumanProfileActions, showProfileActions, ]); diff --git a/desktop/src/features/pulse/ui/NoteCard.tsx b/desktop/src/features/pulse/ui/NoteCard.tsx index 142d725e97..6c82a8ff07 100644 --- a/desktop/src/features/pulse/ui/NoteCard.tsx +++ b/desktop/src/features/pulse/ui/NoteCard.tsx @@ -65,9 +65,11 @@ function ReplyParentContext({ ); const fetchedProfile = parentProfileQuery.data ?? null; const parentDisplayName = parentNote - ? (cachedProfile?.displayName ?? - fetchedProfile?.displayName ?? - truncatePubkey(parentNote.pubkey)) + ? cachedProfile?.displayName?.trim() || + cachedProfile?.name?.trim() || + fetchedProfile?.displayName?.trim() || + fetchedProfile?.name?.trim() || + truncatePubkey(parentNote.pubkey) : null; const parentAvatarUrl = cachedProfile?.avatarUrl ?? fetchedProfile?.avatarUrl ?? null; @@ -143,7 +145,10 @@ export function NoteCard({ members = [], actions, }: NoteCardProps) { - const displayName = profile?.displayName ?? truncatePubkey(note.pubkey); + const displayName = + profile?.displayName?.trim() || + profile?.name?.trim() || + truncatePubkey(note.pubkey); const avatarUrl = profile?.avatarUrl ?? null; const [isReplyComposerOpen, setIsReplyComposerOpen] = React.useState(false); const actionButtonClass = diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index 8a1283b71c..ab68fcb02f 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -223,10 +223,12 @@ export function ProfileSettingsCard({ const updateProfileMutation = useUpdateProfileMutation(); const profile = profileQuery.data; + const currentName = profile?.name ?? ""; const currentDisplayName = profile?.displayName ?? ""; const currentAvatarUrl = profile?.avatarUrl ?? ""; const currentAbout = profile?.about ?? ""; const [displayNameDraft, setDisplayNameDraft] = React.useState(""); + const [nameDraft, setNameDraft] = React.useState(""); const [avatarUrlDraft, setAvatarUrlDraft] = React.useState(""); const [aboutDraft, setAboutDraft] = React.useState(""); const [uploadedAvatarUrlDraft, setUploadedAvatarUrlDraft] = React.useState< @@ -261,6 +263,12 @@ export function ProfileSettingsCard({ const savedScrollTopRef = React.useRef(null); isEditingProfileMetadataRef.current = isEditingProfileMetadata; + React.useEffect(() => { + if (!isEditingProfileMetadataRef.current) { + setNameDraft(currentName); + } + }, [currentName]); + React.useEffect(() => { if (!isEditingProfileMetadataRef.current) { setDisplayNameDraft(currentDisplayName); @@ -330,11 +338,13 @@ export function ProfileSettingsCard({ }, []); const nextDisplayName = displayNameDraft.trim(); + const nextName = nameDraft.trim(); const nextAvatarUrl = avatarUrlDraft.trim(); const nextAbout = aboutDraft.trim(); const updatePayload = React.useMemo(() => { const payload: { displayName?: string; + name?: string; avatarUrl?: string; about?: string; } = {}; @@ -342,6 +352,9 @@ export function ProfileSettingsCard({ if (nextDisplayName.length > 0 && nextDisplayName !== currentDisplayName) { payload.displayName = nextDisplayName; } + if (nextName !== currentName) { + payload.name = nextName; + } if (nextAvatarUrl.length > 0 && nextAvatarUrl !== currentAvatarUrl) { payload.avatarUrl = nextAvatarUrl; } @@ -354,9 +367,11 @@ export function ProfileSettingsCard({ currentAbout, currentAvatarUrl, currentDisplayName, + currentName, nextAbout, nextAvatarUrl, nextDisplayName, + nextName, ]); const hasPendingDisplayNameClearRequest = @@ -382,8 +397,9 @@ export function ProfileSettingsCard({ const resolvedName = nextDisplayName || - profile?.displayName || - fallbackDisplayName || + profile?.displayName?.trim() || + profile?.name?.trim() || + fallbackDisplayName?.trim() || "Your profile"; const resolvedPubkey = profile?.pubkey ?? currentPubkey ?? "Unavailable"; const nip05Handle = profile?.nip05Handle ?? "Not set"; @@ -480,21 +496,16 @@ export function ProfileSettingsCard({ return false; } - await updateProfileMutation.mutateAsync(updatePayload); + const updatedProfile = + await updateProfileMutation.mutateAsync(updatePayload); setIsEditingProfileMetadata(false); - setDisplayNameDraft(updatePayload.displayName ?? currentDisplayName); - setAvatarUrlDraft(updatePayload.avatarUrl ?? currentAvatarUrl); - setAboutDraft(updatePayload.about ?? currentAbout); + setNameDraft(updatedProfile.name ?? ""); + setDisplayNameDraft(updatedProfile.displayName ?? ""); + setAvatarUrlDraft(updatedProfile.avatarUrl ?? ""); + setAboutDraft(updatedProfile.about ?? ""); toast.success("Profile saved"); return true; - }, [ - canSave, - currentAbout, - currentAvatarUrl, - currentDisplayName, - updatePayload, - updateProfileMutation, - ]); + }, [canSave, updatePayload, updateProfileMutation]); const handleProfileMetadataEdit = React.useCallback(() => { if (!isEditingProfileMetadata) { @@ -806,6 +817,42 @@ export function ProfileSettingsCard({ +
+
+ +

+ A short profile name. It does not need to be + unique. +

+ {isEditingProfileMetadata ? ( + + setNameDraft(event.target.value) + } + placeholder="e.g. theangrypit" + value={nameDraft} + /> + ) : ( +

+ {nameDraft || "Not set"} +

+ )} +
+
+