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;