From d618a5a4a06b6b441b9e68523b0b944b3a426b61 Mon Sep 17 00:00:00 2001 From: Avi Date: Sun, 23 Aug 2026 18:48:45 -0500 Subject: [PATCH] Add profile rename with metadata republish (GUI + CLI) --- frontend/electron/main.ts | 1 + frontend/src/lib/api.ts | 5 + frontend/src/screens/ProfilesScreen.tsx | 100 +++++++++++++++++ frontend/src/state/AppProvider.tsx | 12 +++ frontend/src/test/ProfilesScreen.test.tsx | 21 ++++ frontend/src/test/fakeBackend.ts | 19 ++++ src/ipc.rs | 19 ++++ src/main.rs | 23 ++++ src/profiles.rs | 124 ++++++++++++++++++++++ 9 files changed, 324 insertions(+) diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index ce51864..b8f0637 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -185,6 +185,7 @@ const RENDERER_METHODS: ReadonlySet = new Set([ 'select_profile', 'publish_profile_metadata', 'set_profile_picture', + 'rename_profile', 'delete_profile', 'undo_delete', 'publish_note', diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d340b7f..9ee9f7f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -58,6 +58,11 @@ export const api = { 'set_profile_picture', { npub, url }, ), + renameProfile: (npub: string, label: string) => + call<{ profile: ProfileSummary; report: MetadataPublishReport; state: AppState }>( + 'rename_profile', + { npub, label }, + ), publishNote: (content: string) => call('publish_note', { content }), feedGet: (limit?: number, contactsOnly = false) => call('feed_get', { diff --git a/frontend/src/screens/ProfilesScreen.tsx b/frontend/src/screens/ProfilesScreen.tsx index c75902b..d437d94 100644 --- a/frontend/src/screens/ProfilesScreen.tsx +++ b/frontend/src/screens/ProfilesScreen.tsx @@ -24,6 +24,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { const [errorId] = useState(() => `profiles-error-${Math.random().toString(36).slice(2)}`); const [revealTarget, setRevealTarget] = useState<{ label: string; npub: string } | null>(null); const [pictureTarget, setPictureTarget] = useState(null); + const [renameTarget, setRenameTarget] = useState<{ npub: string; label: string } | null>(null); const profiles = state?.profiles ?? []; const shorten = state?.settings.shorten_npub ?? true; @@ -169,6 +170,13 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { > Publish name + + + + + + ); +} + function PictureModal({ target, onClose, diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index b248a2d..6e971e5 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -41,6 +41,7 @@ interface AppContextValue { selectProfile: (npub: string) => Promise; publishProfileMetadata: (npub: string) => Promise; setProfilePicture: (npub: string, url: string | null) => Promise; + renameProfile: (npub: string, label: string) => Promise; publishNote: (content: string) => Promise; recordPublishFailure: (message: string, details?: string | null) => void; clearLastPublish: () => void; @@ -134,6 +135,15 @@ export function AppProvider({ children }: { children: ReactNode }) { [], ); + const renameProfile = useCallback( + async (npub: string, label: string): Promise => { + const result = await api.renameProfile(npub, label); + setState(result.state); + return result.report; + }, + [], + ); + const publishNote = useCallback(async (content: string): Promise => { const report = await api.publishNote(content); setLastPublish({ report, error: null, details: null, at: Date.now() }); @@ -250,6 +260,7 @@ export function AppProvider({ children }: { children: ReactNode }) { undoDelete, publishProfileMetadata, setProfilePicture, + renameProfile, copyText, }), [ @@ -262,6 +273,7 @@ export function AppProvider({ children }: { children: ReactNode }) { selectProfile, publishProfileMetadata, setProfilePicture, + renameProfile, publishNote, deleteProfile, undoDelete, diff --git a/frontend/src/test/ProfilesScreen.test.tsx b/frontend/src/test/ProfilesScreen.test.tsx index 0b9cdc6..ff5fa99 100644 --- a/frontend/src/test/ProfilesScreen.test.tsx +++ b/frontend/src/test/ProfilesScreen.test.tsx @@ -65,6 +65,27 @@ describe('ProfilesScreen', () => { expect(backend.state.active_profile?.npub).toBe(ALICE); }); + it('renames a profile from the Edit name modal and publishes it', async () => { + const backend = createFakeBackend(); + installFakeBackend(backend); + const user = userEvent.setup(); + renderWithApp(); + + await screen.findByText('Bob'); + await user.click(screen.getAllByRole('button', { name: 'Edit name' })[1]); + + const input = screen.getByLabelText('Profile name'); + expect(input).toHaveValue('Bob'); + await user.clear(input); + await user.type(input, 'Bobby'); + await user.click(screen.getByRole('button', { name: 'Save & publish' })); + + await waitFor(() => { + expect(backend.state.profiles.find((p) => p.npub === BOB)?.label).toBe('Bobby'); + }); + expect(await screen.findByRole('status')).toHaveTextContent(/Renamed to "Bobby"/); + }); + it('disables Select for the active profile and copies public keys', async () => { const backend = createFakeBackend(); installFakeBackend(backend); diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index 314c1e4..0e916a0 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -163,6 +163,25 @@ export function createFakeBackend(initial?: AppState): FakeBackend { return next; } + case 'rename_profile': { + const npub = String(params.npub); + const label = String(params.label ?? '').trim(); + if (!label) { + throw new Error('The profile name cannot be empty.'); + } + if (!state.profiles.some((p) => p.npub === npub)) { + throw new Error('That profile is not stored on this computer.'); + } + const updated: ProfileSummary = { ...state.profiles.find((p) => p.npub === npub)!, label }; + const next: AppState = { + ...state, + profiles: state.profiles.map((p) => (p.npub === npub ? updated : p)), + active_profile: state.active_profile?.npub === npub ? updated : state.active_profile, + }; + backend.setState(next); + return { profile: updated, report: makePublishReport(), state: next }; + } + case 'publish_note': { if (publishFailure) { const failure = publishFailure; diff --git a/src/ipc.rs b/src/ipc.rs index e51c80c..89c75ea 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -49,6 +49,12 @@ pub enum Request { npub: String, url: Option, }, + /// Change a profile's label and publish it as part of the profile's kind 0 + /// metadata. + RenameProfile { + npub: String, + label: String, + }, PublishNote { content: String, }, @@ -325,6 +331,19 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { + let key = app.vault_key().copied(); + let (summary, report) = profiles::rename_profile( + &mut app.vault, + &npub, + label, + key.as_ref(), + &app.settings, + )?; + app.save_vault()?; + Ok(json!({ "profile": summary, "report": report, "state": app.state_view() })) + } + Request::PublishNote { content } => { let report = publish::publish_active(&app.vault, &app.settings, &content, app.vault_key()) diff --git a/src/main.rs b/src/main.rs index 92cf91a..725c999 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ Commands: publish Publish a text note as a specific profile publish-name Publish the profile's stored name so other clients show it set-picture Set the profile picture (http(s) URL) and publish it + rename Rename a profile and publish the new name feed [--contacts] [limit] Fetch recent notes from enabled relays (default 50); --contacts filters to the active profile's contacts relays list List configured relays @@ -70,6 +71,7 @@ async fn main() -> ExitCode { "publish" => cli_publish(&args).await, "publish-name" => cli_publish_name(&args), "set-picture" => cli_set_picture(&args), + "rename" => cli_rename(&args), "feed" => cli_feed(&args).await, "relays" => cli_relays(&args).await, "settings" => cli_settings(&args), @@ -187,6 +189,27 @@ fn cli_set_picture(args: &[String]) -> Result { )) } +fn cli_rename(args: &[String]) -> Result { + let npub = args + .get(2) + .ok_or_else(|| AppError::config("Usage: keynectr rename "))?; + let label = args[3..].join(" "); + if label.trim().is_empty() { + return Err(AppError::config("Usage: keynectr rename ")); + } + + let mut app = load_app_with_unlock()?; + let key = app.vault_key().copied(); + let (summary, report) = + profiles::rename_profile(&mut app.vault, npub, label, key.as_ref(), &app.settings)?; + app.save_vault()?; + Ok(format!( + "Renamed to \"{}\"; accepted by {} relay(s).", + summary.label, + report.succeeded.len() + )) +} + fn cli_publish_name(args: &[String]) -> Result { let npub = args .get(2) diff --git a/src/profiles.rs b/src/profiles.rs index 2973246..3d42a3f 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -195,6 +195,62 @@ pub fn set_profile_picture( Ok((summary, report)) } +/// Change a profile's label and immediately republish it as the profile's +/// kind 0 metadata so external clients show the new name. +/// +/// Returns the updated summary plus the per-relay publish report. +pub fn rename_profile( + vault: &mut Vault, + npub: &str, + label: String, + key: Option<&VaultKey>, + settings: &Settings, +) -> Result<(ProfileSummary, MetadataPublishReport), AppError> { + let trimmed = label.trim(); + if trimmed.is_empty() { + return Err(AppError::config("The profile name cannot be empty.")); + } + + // Resolve and sign before mutating so a locked vault or bad key changes + // nothing on disk. + let secret_hex = resolve_secret_key(vault, npub, key)?; + let secret_key = parse_secret_key(&secret_hex)?; + + let stored = find_profile_mut(vault, npub)?; + stored.label = trimmed.to_string(); + let (label, created_at, public_key, picture) = ( + stored.label.clone(), + stored.created_at, + stored.public_key.clone(), + stored.picture.clone(), + ); + let summary = ProfileSummary { + label, + npub: public_key.clone(), + created_at, + is_active: vault.active_profile.as_deref() == Some(public_key.as_str()), + picture, + }; + + let relay_urls = relays::enabled_urls(settings); + if relay_urls.is_empty() { + // The vault change stands; publishing can be retried later via the + // explicit "publish name" action once a relay is enabled. + return Ok(( + summary, + MetadataPublishReport { + succeeded: Vec::new(), + failed: Vec::new(), + }, + )); + } + + let keys = Keys::new(secret_key); + let report = + publish_metadata_blocking(&keys, &summary.label, summary.picture.clone(), relay_urls); + Ok((summary, report)) +} + /// Validate that a picture URL is a well-formed http(s) URL. fn validate_picture_url(url: &str) -> Result<(), AppError> { let parsed = Url::parse(url) @@ -756,6 +812,74 @@ mod tests { assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound); } + #[test] + fn rename_profile_updates_label_and_skips_publish_without_relays() { + let mut vault = Vault::empty(); + let summary = + create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap(); + + let (renamed, report) = rename_profile( + &mut vault, + &summary.npub, + "Alicia".to_string(), + None, + &offline_settings(), + ) + .expect("renaming must work offline"); + + assert_eq!(renamed.label, "Alicia"); + assert_eq!( + vault.profiles[0].label, "Alicia", + "vault must remember the label" + ); + // No relays enabled: nothing published, but the change still stands. + assert!(report.succeeded.is_empty()); + assert!(report.failed.is_empty()); + + // Leading/trailing whitespace is trimmed. + let (trimmed, _) = rename_profile( + &mut vault, + &summary.npub, + " Ace ".to_string(), + None, + &offline_settings(), + ) + .unwrap(); + assert_eq!(trimmed.label, "Ace"); + assert_eq!(vault.profiles[0].label, "Ace"); + } + + #[test] + fn rename_profile_rejects_empty_names() { + let mut vault = Vault::empty(); + let summary = + create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap(); + + for bad in [String::new(), " ".to_string()] { + let err = rename_profile(&mut vault, &summary.npub, bad, None, &offline_settings()) + .expect_err("empty name must error"); + assert_eq!(err.kind(), crate::errors::ErrorKind::Config); + } + assert_eq!( + vault.profiles[0].label, "Alice", + "nothing stored on failure" + ); + } + + #[test] + fn rename_profile_missing_profile_errors() { + let mut vault = Vault::empty(); + let err = rename_profile( + &mut vault, + "npub1ghost", + "Ghost".to_string(), + None, + &offline_settings(), + ) + .expect_err("missing profile must error"); + assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound); + } + /// Delete a profile by npub, returning the deleted profile for undo. /// The vault must not be encrypted, or the key must be provided. pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result {