Add profile rename with metadata republish (GUI + CLI)

This commit is contained in:
Avi 2026-08-23 18:48:45 -05:00
commit d618a5a4a0
9 changed files with 324 additions and 0 deletions

View file

@ -185,6 +185,7 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
'select_profile',
'publish_profile_metadata',
'set_profile_picture',
'rename_profile',
'delete_profile',
'undo_delete',
'publish_note',

View file

@ -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<PublishReport>('publish_note', { content }),
feedGet: (limit?: number, contactsOnly = false) =>
call<FeedItem[]>('feed_get', {

View file

@ -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<PictureTarget | null>(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) {
>
<Icon name="publish" size={14} /> Publish name
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setRenameTarget({ npub: profile.npub, label: profile.label })}
>
<Icon name="edit" size={14} /> Edit name
</Button>
<Button
variant="ghost"
size="sm"
@ -239,6 +247,19 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
onSavingChange={setPublishing}
/>
)}
{renameTarget && (
<RenameModal
target={renameTarget}
onClose={() => setRenameTarget(null)}
onSaved={(message) => {
setNotice(message);
setRenameTarget(null);
}}
onError={setError}
onSavingChange={setPublishing}
/>
)}
</div>
</div>
);
@ -250,6 +271,85 @@ interface PictureTarget {
url: string;
}
function RenameModal({
target,
onClose,
onSaved,
onError,
onSavingChange,
}: {
target: { npub: string; label: string };
onClose: () => void;
onSaved: (message: string) => void;
onError: (message: string | null) => void;
onSavingChange: (npub: string | null) => void;
}) {
const { renameProfile } = useApp();
const [label, setLabel] = useState(target.label);
const [saving, setSaving] = useState(false);
const trimmed = label.trim();
const canSave = trimmed.length > 0 && trimmed !== target.label;
const save = async () => {
if (!canSave) {
return;
}
onError(null);
setSaving(true);
onSavingChange(target.npub);
try {
const report = await renameProfile(target.npub, trimmed);
onSaved(
report.failed.length === 0
? `Renamed to "${trimmed}" and published to ${report.succeeded.length} relay(s). It may take a minute to appear on other clients.`
: `Renamed to "${trimmed}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`,
);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
onSavingChange(null);
}
};
return (
<Modal open title={`Edit profile name — ${target.label}`} onClose={onClose}>
<div className="picture-modal">
<div className="field">
<label htmlFor="profile-name">Profile name</label>
<input
id="profile-name"
type="text"
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder="My Profile"
autoComplete="off"
autoFocus
/>
</div>
<p className="muted">
The name is stored on this computer and published to your enabled relays so other Nostr
clients show it.
</p>
<div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void save()}
loading={saving}
disabled={!canSave}
>
Save &amp; publish
</Button>
</div>
</div>
</Modal>
);
}
function PictureModal({
target,
onClose,

View file

@ -41,6 +41,7 @@ interface AppContextValue {
selectProfile: (npub: string) => Promise<void>;
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
renameProfile: (npub: string, label: string) => Promise<MetadataPublishReport>;
publishNote: (content: string) => Promise<PublishReport>;
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<MetadataPublishReport> => {
const result = await api.renameProfile(npub, label);
setState(result.state);
return result.report;
},
[],
);
const publishNote = useCallback(async (content: string): Promise<PublishReport> => {
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,

View file

@ -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(<ProfilesScreen onCreateProfile={vi.fn()} />);
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);

View file

@ -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;

View file

@ -49,6 +49,12 @@ pub enum Request {
npub: String,
url: Option<String>,
},
/// 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<serde_json::Val
Ok(json!({ "profile": summary, "report": report, "state": app.state_view() }))
}
Request::RenameProfile { npub, label } => {
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())

View file

@ -21,6 +21,7 @@ Commands:
publish <npub> <content> Publish a text note as a specific profile
publish-name <npub> Publish the profile's stored name so other clients show it
set-picture <npub> <url> Set the profile picture (http(s) URL) and publish it
rename <npub> <new-name> 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<String, AppError> {
))
}
fn cli_rename(args: &[String]) -> Result<String, AppError> {
let npub = args
.get(2)
.ok_or_else(|| AppError::config("Usage: keynectr rename <npub> <new-name>"))?;
let label = args[3..].join(" ");
if label.trim().is_empty() {
return Err(AppError::config("Usage: keynectr rename <npub> <new-name>"));
}
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<String, AppError> {
let npub = args
.get(2)

View file

@ -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<ProfileSummary, AppError> {