From 6342d1aa5484bf1d24e4311016270a2796a33559 Mon Sep 17 00:00:00 2001 From: Vitor Cepeda Lopes Date: Tue, 28 Jul 2026 18:26:31 +0100 Subject: [PATCH 1/7] fix(desktop): separate Nostr name from display name Signed-off-by: Vitor Cepeda Lopes --- desktop/src-tauri/src/commands/profile.rs | 175 ++++++++++++++---- desktop/src-tauri/src/events.rs | 33 +--- desktop/src-tauri/src/events/profile.rs | 76 ++++++++ desktop/src-tauri/src/models.rs | 1 + desktop/src-tauri/src/nostr_convert.rs | 6 +- .../onboarding/ui/CommunityOnboardingFlow.tsx | 71 +++++-- .../features/onboarding/ui/OnboardingFlow.tsx | 27 ++- .../features/onboarding/ui/ProfileStep.tsx | 38 +++- desktop/src/features/onboarding/ui/types.ts | 3 + desktop/src/features/profile/hooks.ts | 3 + .../profile/lib/selfProfileStorage.test.mjs | 4 + .../profile/lib/selfProfileStorage.ts | 5 + .../src/features/profile/profileCacheSync.ts | 1 + .../profile/ui/UserProfilePanelUtils.ts | 1 + .../settings/ui/ProfileSettingsCard.tsx | 70 +++++-- desktop/src/shared/api/tauriProfiles.ts | 2 + desktop/src/shared/api/types.ts | 4 + desktop/src/testing/e2eBridge.ts | 47 ++++- .../tests/e2e/onboarding-avatar-skip.spec.ts | 14 ++ desktop/tests/e2e/onboarding.spec.ts | 64 ++++++- desktop/tests/e2e/profile.spec.ts | 15 ++ 21 files changed, 554 insertions(+), 106 deletions(-) create mode 100644 desktop/src-tauri/src/events/profile.rs diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index ef67fac570..07badf08a7 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,17 @@ 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(), + }, + )?; submit_event(builder, &state).await?; // Re-fetch to return canonical profile. @@ -123,9 +113,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 +136,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 +406,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 +454,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 +472,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..db11cd119e 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -288,10 +288,11 @@ 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 name = v.get("name").and_then(Value::as_str).map(str::to_string); let display_name = v .get("display_name") .and_then(Value::as_str) - .or_else(|| v.get("name").and_then(Value::as_str)) + .or(name.as_deref()) .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); @@ -299,6 +300,7 @@ pub fn profile_info_from_event(event: &Event) -> Result { Ok(ProfileInfo { pubkey: event.pubkey.to_hex(), + name, display_name, avatar_url, about, @@ -766,6 +768,7 @@ mod tests { vec![], ); let p = profile_info_from_event(&e).unwrap(); + assert_eq!(p.name.as_deref(), Some("alice")); assert_eq!(p.display_name.as_deref(), Some("Alice")); assert_eq!(p.avatar_url.as_deref(), Some("http://x/a.png")); assert_eq!(p.about.as_deref(), Some("hi")); @@ -786,6 +789,7 @@ mod tests { 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.name.as_deref(), Some("bob")); assert_eq!(p.display_name.as_deref(), Some("bob")); } diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 4b729ab33f..3f6e557f24 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); @@ -301,7 +304,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 +391,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 +412,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 +520,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 +536,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 +652,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/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 04546804eb..d26af3f220 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, }, ); } 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/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 07f57803b4..bc758a3bb2 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, diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index 8a1283b71c..d4d15be732 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 = @@ -480,21 +495,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 +816,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"} +

+ )} +
+
+