Add NIP-05 identifiers: store, publish, GUI modal, CLI helpers

- Store an optional nip05 on each profile; publish it in kind 0 metadata
- set-nip05 CLI (+ validation, lower-casing, clear) and nip05-file helper
  that prints the .well-known/nostr.json document for a domain
- Profiles screen: NIP-05 button, handle shown on cards, Nip05Modal with
  client-side validation and Remove action
- Fix Modal stealing focus from autoFocus inputs one frame after open
This commit is contained in:
Avi 2026-08-23 21:59:11 -05:00
commit 045fa47476
14 changed files with 666 additions and 11 deletions

View file

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

View file

@ -19,7 +19,11 @@ export function Modal({ open, title, onClose, children }: ModalProps) {
const container = containerRef.current;
const frame = requestAnimationFrame(() => {
container?.focus();
// Keep keyboard focus where the user (or autoFocus) put it; only pull
// focus to the dialog itself as a fallback for content without inputs.
if (!container?.contains(document.activeElement)) {
container?.focus();
}
});
const onKeyDown = (event: KeyboardEvent) => {

View file

@ -63,6 +63,11 @@ export const api = {
'rename_profile',
{ npub, label },
),
setNip05: (npub: string, nip05: string | null) =>
call<{ profile: ProfileSummary; report: MetadataPublishReport; state: AppState }>('set_nip05', {
npub,
nip05,
}),
publishNote: (content: string) => call<PublishReport>('publish_note', { content }),
feedGet: (limit?: number, contactsOnly = false) =>
call<FeedItem[]>('feed_get', {

View file

@ -36,6 +36,8 @@ export interface ProfileSummary {
is_active: boolean;
/** Public URL of the profile picture, when one has been set. */
picture?: string | null;
/** NIP-05 identifier (e.g. `boo@l484.com`), when one has been set. */
nip05?: string | null;
}
export interface RelayConfig {

View file

@ -25,6 +25,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
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 [nip05Target, setNip05Target] = useState<Nip05Target | null>(null);
const profiles = state?.profiles ?? [];
const shorten = state?.settings.shorten_npub ?? true;
@ -151,6 +152,11 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
/>
<div className="profile-card-meta">
<h3>{profile.label}</h3>
{profile.nip05 && (
<span className="nip05-handle" title={profile.nip05}>
{profile.nip05}
</span>
)}
<code className="mono" title={profile.npub}>
{shortenNpub(profile.npub, shorten)}
</code>
@ -177,6 +183,19 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
>
<Icon name="edit" size={14} /> Edit name
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
setNip05Target({
npub: profile.npub,
label: profile.label,
nip05: profile.nip05 ?? '',
})
}
>
<Icon name="edit" size={14} /> NIP-05
</Button>
<Button
variant="ghost"
size="sm"
@ -260,6 +279,19 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
onSavingChange={setPublishing}
/>
)}
{nip05Target && (
<Nip05Modal
target={nip05Target}
onClose={() => setNip05Target(null)}
onSaved={(message) => {
setNotice(message);
setNip05Target(null);
}}
onError={setError}
onSavingChange={setPublishing}
/>
)}
</div>
</div>
);
@ -271,6 +303,119 @@ interface PictureTarget {
url: string;
}
interface Nip05Target {
npub: string;
label: string;
nip05: string;
}
/** Client-side mirror of the backend's NIP-05 validation, for fast feedback. */
function nip05Problem(value: string): string | null {
const trimmed = value.trim().toLowerCase();
if (!trimmed) {
return null; // Empty clears the identifier.
}
const at = trimmed.indexOf('@');
if (at <= 0 || at === trimmed.length - 1) {
return 'Use the form name@domain.com.';
}
const [local, domain] = [trimmed.slice(0, at), trimmed.slice(at + 1)];
if (local !== '_' && !/^[a-z0-9_-]+$/.test(local)) {
return 'The part before @ may only use letters, numbers, dashes and underscores.';
}
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) {
return 'The part after @ must be a domain like example.com.';
}
return null;
}
function Nip05Modal({
target,
onClose,
onSaved,
onError,
onSavingChange,
}: {
target: Nip05Target;
onClose: () => void;
onSaved: (message: string) => void;
onError: (message: string | null) => void;
onSavingChange: (npub: string | null) => void;
}) {
const { setNip05 } = useApp();
const [nip05, setNip05Value] = useState(target.nip05);
const [saving, setSaving] = useState(false);
const trimmed = nip05.trim();
const changed = trimmed !== target.nip05;
const problem = nip05Problem(trimmed);
const canSave = changed && !problem;
const save = async (next: string | null) => {
onError(null);
setSaving(true);
onSavingChange(target.npub);
try {
const report = await setNip05(target.npub, next);
onSaved(
report.failed.length === 0
? next
? `NIP-05 "${next}" published to ${report.succeeded.length} relay(s). Remember it must also be served from your domain (see docs/NIP-05.md).`
: 'NIP-05 removed and the change published.'
: `NIP-05 saved for "${target.label}", 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={`NIP-05 address — ${target.label}`} onClose={onClose}>
<div className="picture-modal">
<div className="field">
<label htmlFor="profile-nip05">NIP-05 address</label>
<input
id="profile-nip05"
type="text"
value={nip05}
onChange={(event) => setNip05Value(event.target.value)}
placeholder="name@domain.com"
autoComplete="off"
autoFocus
/>
{problem && trimmed && <ErrorText>{problem}</ErrorText>}
</div>
<p className="muted">
A NIP-05 address (like an email handle) makes clients show a proper username instead of a
raw key. It is published to your relays and must also be served from your domain as
<code> /.well-known/nostr.json</code> — see <code>docs/NIP-05.md</code>.
</p>
<div className="modal-actions">
{target.nip05 && (
<Button variant="danger" onClick={() => void save(null)} disabled={saving}>
Remove
</Button>
)}
<Button variant="ghost" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void save(trimmed ? trimmed.toLowerCase() : null)}
loading={saving}
disabled={!canSave}
>
Save &amp; publish
</Button>
</div>
</div>
</Modal>
);
}
function RenameModal({
target,
onClose,

View file

@ -42,6 +42,7 @@ interface AppContextValue {
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
renameProfile: (npub: string, label: string) => Promise<MetadataPublishReport>;
setNip05: (npub: string, nip05: string | null) => Promise<MetadataPublishReport>;
publishNote: (content: string) => Promise<PublishReport>;
recordPublishFailure: (message: string, details?: string | null) => void;
clearLastPublish: () => void;
@ -144,6 +145,15 @@ export function AppProvider({ children }: { children: ReactNode }) {
[],
);
const setNip05 = useCallback(
async (npub: string, nip05: string | null): Promise<MetadataPublishReport> => {
const result = await api.setNip05(npub, nip05);
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() });
@ -261,6 +271,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
publishProfileMetadata,
setProfilePicture,
renameProfile,
setNip05,
copyText,
}),
[
@ -274,6 +285,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
publishProfileMetadata,
setProfilePicture,
renameProfile,
setNip05,
publishNote,
deleteProfile,
undoDelete,

View file

@ -86,6 +86,69 @@ describe('ProfilesScreen', () => {
expect(await screen.findByRole('status')).toHaveTextContent(/Renamed to "Bobby"/);
});
it('sets a NIP-05 address from the NIP-05 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: 'NIP-05' })[1]);
const input = screen.getByLabelText('NIP-05 address');
expect(input).toHaveValue('');
await user.type(input, 'Bob@Example.com');
await user.click(screen.getByRole('button', { name: 'Save & publish' }));
await waitFor(() => {
expect(backend.state.profiles.find((p) => p.npub === BOB)?.nip05).toBe('bob@example.com');
});
expect(await screen.findByRole('status')).toHaveTextContent(/NIP-05 "bob@example.com"/);
expect(await screen.findByText('bob@example.com')).toBeInTheDocument();
});
it('rejects a malformed NIP-05 address without calling the backend', 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: 'NIP-05' })[1]);
const input = screen.getByLabelText('NIP-05 address');
await user.type(input, 'not-an-address');
expect(screen.getByRole('button', { name: 'Save & publish' })).toBeDisabled();
await user.clear(input);
await user.type(input, 'boo@nodot');
expect(screen.getByRole('button', { name: 'Save & publish' })).toBeDisabled();
expect(backend.requests.filter((r) => r.method === 'set_nip05')).toHaveLength(0);
});
it('removes an existing NIP-05 address', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
backend.setState({
...backend.state,
profiles: backend.state.profiles.map((p) =>
p.npub === BOB ? { ...p, nip05: 'bob@example.com' } : p,
),
active_profile: backend.state.active_profile,
});
const user = userEvent.setup();
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
await screen.findByText('bob@example.com');
await user.click(screen.getAllByRole('button', { name: 'NIP-05' })[1]);
await user.click(screen.getByRole('button', { name: 'Remove' }));
await waitFor(() => {
expect(backend.state.profiles.find((p) => p.npub === BOB)?.nip05).toBeNull();
});
expect(await screen.findByRole('status')).toHaveTextContent(/NIP-05 removed/i);
});
it('disables Select for the active profile and copies public keys', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);

View file

@ -182,6 +182,39 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
return { profile: updated, report: makePublishReport(), state: next };
}
case 'set_nip05': {
const npub = String(params.npub);
const raw = params.nip05;
const nip05 = typeof raw === 'string' ? raw.trim().toLowerCase() : null;
if (nip05) {
const at = nip05.indexOf('@');
const [local, domain] = [nip05.slice(0, at), nip05.slice(at + 1)];
if (at <= 0 || at === nip05.length - 1 || nip05.includes('@', at + 1)) {
throw new Error('A NIP-05 address must look like name@domain.com.');
}
if (local !== '_' && !/^[a-z0-9_-]+$/.test(local)) {
throw new Error(
'The part before @ may only use letters, numbers, dashes and underscores.',
);
}
if (!domain.includes('.') || domain.split('.').some((part) => !part)) {
throw new Error('The part after @ must be a domain like example.com.');
}
}
const existing = state.profiles.find((p) => p.npub === npub);
if (!existing) {
throw new Error('That profile is not stored on this computer.');
}
const updated: ProfileSummary = { ...existing, nip05: nip05 || null };
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;