Add profile rename with metadata republish (GUI + CLI)
This commit is contained in:
parent
808f011876
commit
d618a5a4a0
9 changed files with 324 additions and 0 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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 & publish
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function PictureModal({
|
||||
target,
|
||||
onClose,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue