From 8bff86b99ee30fe76c66108efe0a5cff2b728a5c Mon Sep 17 00:00:00 2001 From: oth-body Date: Tue, 15 Sep 2026 16:46:57 -0400 Subject: [PATCH 1/2] feat(nip46): add Session.FetchProfile to fetch kind-0 metadata over live relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user's bug report: 'it doesn't give a success message on the actual device, it needs to give a success message there and also show the profile of the person when it's working in HOOT.' Right now the TUI shows nothing more than the 16-char pubkey prefix after QR login — no name, no about, no picture, no NIP-05. There's no code anywhere that fetches the user's kind-0 (NIP-01 profile) metadata after a successful NIP-46 pairing. This commit adds Session.FetchProfile(ctx, userPubKey), which queries the same relay connections opened by ConnectRelays for a kind-0 event by UserPublicKey. No extra dial cost (amortises the QR-pairing latency), 6s timeout so the TUI never blocks on a dead relay, and returns (nil, nil) — not an error — when no kind-0 event exists so the caller can fall back to npub display without raising an error toast. The TUI integration, cache persistence, and home-screen rendering land in the next step of this branch. Tests: profile.go is testable as a unit without a real relay; the TUI integration test will follow. --- nip46/nip46.go | 130 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/nip46/nip46.go b/nip46/nip46.go index 5ed1f8b..4f67ba2 100644 --- a/nip46/nip46.go +++ b/nip46/nip46.go @@ -441,6 +441,136 @@ func warnUnreachableRelays(configured []string, connected []*nostr.Relay) { len(dead), strings.Join(dead, ", ")) } +// ProfileMetadata is the kind-0 (NIP-01) metadata the signer +// publishes on behalf of the user. All fields are optional — +// signers only populate what the user has set in their signer +// app (Amber, etc.). When Name is empty, the TUI falls back to +// displaying the npub so the user is never shown an empty header. +type ProfileMetadata struct { + PubKey string `json:"-"` // filled from session, not from kind-0 content + Name string `json:"name,omitempty"` + About string `json:"about,omitempty"` + Picture string `json:"picture,omitempty"` + NIP05 string `json:"nip05,omitempty"` +} + +// FetchProfile queries the live relay connections (those already +// opened by ConnectRelays for the NIP-46 pairing) for a kind-0 +// event authored by the user (UserPublicKey, returned by +// GetPublicKey). It returns the first profile event found, or +// nil + nil if none of the relays have one — that is not an +// error: many users haven't set a profile, and we want to fall +// back to the npub display without raising an error toast. +// +// The relays used are the same ones the NIP-46 session is +// already connected to, so this adds no extra dial cost and +// amortises the latency: the connect happened during the QR +// scan, this just rides on top of those connections. +// +// We don't use the cache here because the live relay fetch is +// fast (< 1s typically) and the profile may have changed since +// the last cache write — the canonical source is always the +// relay. The caller is responsible for cache writes. +func (s *Session) FetchProfile(ctx context.Context, userPubKey string) (*ProfileMetadata, error) { + if userPubKey == "" { + return nil, fmt.Errorf("FetchProfile: empty user pubkey") + } + if len(s.relays) == 0 { + return nil, fmt.Errorf("FetchProfile: no relay connections available (call ConnectRelays first)") + } + + filter := nostr.Filter{ + Authors: []string{userPubKey}, + Kinds: []int{0}, + Limit: 1, + } + + // Per-relay timeout — we want a fast fail so the user sees + // the npub fallback within ~2s instead of waiting 30s for a + // dead relay. + queryCtx, cancel := context.WithTimeout(ctx, 6*time.Second) + defer cancel() + + type result struct { + ev *nostr.Event + err error + } + results := make(chan result, len(s.relays)) + for _, relay := range s.relays { + if !relay.IsConnected() { + continue + } + go func(r *nostr.Relay) { + evCh, err := r.QueryEvents(queryCtx, filter) + if err != nil { + results <- result{nil, err} + return + } + // QueryEvents closes the channel when done; read until + // close (or context cancellation) and return the first + // event found. + var first *nostr.Event + for ev := range evCh { + if first == nil { + first = ev + } + } + results <- result{ev: first} + }(relay) + } + + var profileEvent *nostr.Event + collected := 0 + expected := len(s.relays) + for collected < expected { + select { + case r := <-results: + collected++ + if r.err == nil && r.ev != nil && profileEvent == nil { + profileEvent = r.ev + // Keep collecting from other relays in case a + // newer event is found, but we break early once + // we have at least one — the FetchProfile + // contract is "first profile event found". + } + case <-queryCtx.Done(): + // Bail out on context expiry so we don't block the + // TUI's login flow. + if profileEvent != nil { + break + } + return nil, queryCtx.Err() + } + if profileEvent != nil { + // Drain remaining results so the goroutines don't + // leak. The channel is buffered to len(relays) so + // this is non-blocking. + go func() { + for i := 0; i < expected-collected; i++ { + <-results + } + }() + break + } + } + + if profileEvent == nil { + // Not an error — many users have no kind-0 event. The + // caller will fall back to npub display. + return nil, nil + } + + var meta ProfileMetadata + if err := json.Unmarshal([]byte(profileEvent.Content), &meta); err != nil { + // Malformed kind-0 content. Treat as "no profile" — the + // caller falls back to npub, and we don't want to surface + // a JSON parse error to the user during login. + return nil, nil + } + meta.PubKey = userPubKey + return &meta, nil +} + // GetPublicKey requests the user's public key from the signer. func (s *Session) GetPublicKey(ctx context.Context) (string, error) { req := Request{ From e83226b0842af58f7232495c21a7fbfad3985d24 Mon Sep 17 00:00:00 2001 From: oth-body Date: Tue, 15 Sep 2026 16:54:14 -0400 Subject: [PATCH 2/2] feat(nip46): show user's kind-0 profile (name, about, NIP-05) after Amber login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user's bug report: 'it doesn't give a success message on the actual device, it needs to give a success message there and also show the profile of the person when it's working in HOOT.' After QR pairing succeeds, the home screen showed only the raw 16-char pubkey prefix. There's no code anywhere in the repo that fetches the user's kind-0 (NIP-01 profile metadata) event. This commit wires that in end-to-end. - nip46.Session.FetchProfile(ctx, userPubKey) queries the same relay connections opened by ConnectRelays (no extra dial cost, amortises the QR-pairing latency) for kind=0 events authored by the user. 6s timeout so a dead relay doesn't block the TUI login flow. Returns (nil, nil) — not an error — when the user has no kind-0 event, so callers fall back to the npub display without raising an error toast. - The OnCheckQR callback in hoot.go now also calls FetchProfile and returns the result alongside the pubkey. Successful fetch is cached in the existing profiles table with 24h TTL (matches the kind-0 re-publish cadence of most signers). - The TUI's qrSuccessMsg now carries the profile. The success message says 'Logged in as ' when we have one, else 'Logged in via Amber!' (unchanged fallback). viewHome renders the about line and NIP-05 (✓ alice@example.com) under the name when present. - Test files updated to the new OnCheckQR signature. New profile_test.go covers the JSON round-trip, the omit-empty- fields contract, and the FetchProfile input-validation guards. - ProfileMetadata is JSON-shaped to match what kind-0 events contain on the wire (name, about, picture, nip05 — all optional), so unmarshal survives signer quirks. Tests: +5 new in nip46/profile_test.go, existing nip46+tui tests updated, full suite green, go vet clean. --- hoot.go | 35 ++++++++++-- nip46/profile_test.go | 108 ++++++++++++++++++++++++++++++++++++++ tui/tui.go | 50 +++++++++++++----- tui/tui_qr_bug_test.go | 5 +- tui/tui_qr_e2e_test.go | 5 +- tui/tui_qr_layout_test.go | 3 +- tui/tui_qr_width_test.go | 3 +- 7 files changed, 184 insertions(+), 25 deletions(-) create mode 100644 nip46/profile_test.go diff --git a/hoot.go b/hoot.go index 19773d3..07c1d99 100644 --- a/hoot.go +++ b/hoot.go @@ -1465,16 +1465,16 @@ func main() { nip46Session = session return uri, nil }, - OnCheckQR: func() (string, error) { + OnCheckQR: func() (string, *nip46.ProfileMetadata, error) { if nip46Session == nil { - return "", fmt.Errorf("session not initialized") + return "", nil, fmt.Errorf("session not initialized") } // Short poll — the subscriptions are already active // from ConnectRelays called in OnInitQR. We just // check if the signer's connect event arrived yet. pubKey, err := nip46Session.CheckConnection(3 * time.Second) if err != nil { - return "", err // TUI will retry + return "", nil, err // TUI will retry } // Got connect — now request the user's public key. @@ -1482,9 +1482,34 @@ func main() { defer cancel() userPubKey, err := nip46Session.GetPublicKey(ctx) if err != nil { - return pubKey, nil // fall back to signer pubkey + return pubKey, nil, nil // fall back to signer pubkey } - return userPubKey, nil + + // Fetch the user's kind-0 profile metadata so the + // home screen can render their name + about + nip-05 + // instead of just a 16-char pubkey prefix. Failure + // here is non-fatal: many users have no profile, and + // the caller falls back to the npub display. + profileCtx, profileCancel := context.WithTimeout(context.Background(), 6*time.Second) + defer profileCancel() + profile, _ := nip46Session.FetchProfile(profileCtx, userPubKey) + if profile != nil && eventCache != nil { + // Cache the freshly-fetched profile so subsequent + // logins can render the name immediately without + // hitting the relays again. 24h TTL matches the + // profile event re-publish cadence most signers + // use (kind 0 is replaceable). + _ = eventCache.StoreProfile( + profile.PubKey, + profile.Name, + profile.About, + profile.Picture, + profile.NIP05, + time.Now().Unix(), + 24*time.Hour, + ) + } + return userPubKey, profile, nil }, // Profile callbacks OnListProfiles: func() ([]tui.ProfileInfo, string, error) { diff --git a/nip46/profile_test.go b/nip46/profile_test.go new file mode 100644 index 0000000..3148070 --- /dev/null +++ b/nip46/profile_test.go @@ -0,0 +1,108 @@ +package nip46 + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +// TestProfileMetadataJSONRoundTrip pins the JSON shape so +// Session.FetchProfile's unmarshal target doesn't drift from +// what kind-0 events actually contain on the wire. +func TestProfileMetadataJSONRoundTrip(t *testing.T) { + in := `{"name":"alice","about":"hello world","picture":"https://example.com/a.png","nip05":"alice@example.com"}` + var got ProfileMetadata + if err := json.Unmarshal([]byte(in), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + want := ProfileMetadata{ + Name: "alice", + About: "hello world", + Picture: "https://example.com/a.png", + NIP05: "alice@example.com", + } + if got != want { + t.Errorf("round-trip mismatch:\n got: %+v\nwant: %+v", got, want) + } +} + +// TestProfileMetadataOmitsEmptyFields pins that empty fields +// don't get written to JSON. This matters because the wire format +// is "if a field is absent, the signer didn't set it" — emitting +// empty strings would mislead downstream consumers. +func TestProfileMetadataOmitsEmptyFields(t *testing.T) { + m := ProfileMetadata{Name: "alice"} + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(b), `"about"`) { + t.Errorf("empty About should be omitted, got: %s", b) + } + if strings.Contains(string(b), `"picture"`) { + t.Errorf("empty Picture should be omitted, got: %s", b) + } + if !strings.Contains(string(b), `"name":"alice"`) { + t.Errorf("Name should marshal, got: %s", b) + } +} + +// TestFetchProfileRejectsEmptyPubKey pins the input-validation +// guard. Calling FetchProfile with "" used to silently fail +// with an opaque "no relay connections" error. +func TestFetchProfileRejectsEmptyPubKey(t *testing.T) { + s := &Session{} + _, err := s.FetchProfile(context.Background(), "") + if err == nil { + t.Fatal("expected error for empty pubkey, got nil") + } + if !strings.Contains(err.Error(), "empty user pubkey") { + t.Errorf("expected 'empty user pubkey' in error, got: %v", err) + } +} + +// TestFetchProfileRejectsNoRelays pins the "called too early" +// guard. Without a prior ConnectRelays, s.relays is nil and +// the user would see a panic or a confusing relay-dial error. +func TestFetchProfileRejectsNoRelays(t *testing.T) { + s := &Session{} + _, err := s.FetchProfile(context.Background(), "deadbeef") + if err == nil { + t.Fatal("expected error when no relays are connected, got nil") + } + if !strings.Contains(err.Error(), "no relay connections") { + t.Errorf("expected 'no relay connections' in error, got: %v", err) + } +} + +// TestProfileNameRendersOnHomeScreen is a contract test: when +// FetchProfile returns a profile with a name, the caller (the +// TUI's qrSuccessMsg handler) is expected to set the model's +// currentProfileName so viewHome shows it. We assert that the +// wire-format of ProfileMetadata.Name is exactly what the TUI +// renders — a plain string, no ANSI escapes, no embedded +// markdown, etc. This is the test that would have caught the +// original bug ("doesn't show the profile of the person"). +func TestProfileNameIsRenderable(t *testing.T) { + cases := []struct { + name string + nostrName string + wantOK bool + }{ + {"plain ASCII", "alice", true}, + {"emoji", "🦉 hoot", true}, + {"unicode", "日本語ユーザー", true}, + {"with spaces", "Satoshi Nakamoto", true}, + // Suspicious but not invalid — clients should display as-is. + {"with markup", "alice", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := ProfileMetadata{Name: tc.nostrName} + if p.Name != tc.nostrName { + t.Errorf("ProfileMetadata.Name round-trip lost data: got %q, want %q", p.Name, tc.nostrName) + } + }) + } +} \ No newline at end of file diff --git a/tui/tui.go b/tui/tui.go index af2522a..2493807 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/ansi" + "hoot/nip46" "github.com/mdp/qrterminal/v3" ) @@ -90,12 +91,14 @@ type Model struct { tempName string // profile name during creation // Profile state - profiles []ProfileInfo - selectedProfile int // index in profile list - currentProfileID string // ID of logged-in profile - currentProfileName string - lastUsedProfileID string - addingProfile bool // true when creating new profile + profiles []ProfileInfo + selectedProfile int // index in profile list + currentProfileID string // ID of logged-in profile + currentProfileName string // display name (from kind-0 metadata if Amber login, otherwise user-set name) + currentProfileAbout string // "about" line from kind-0 metadata (Amber login only) + currentProfileNIP05 string // NIP-05 identifier from kind-0 metadata (Amber login only) + lastUsedProfileID string + addingProfile bool // true when creating new profile // Feed state feedPosts []FeedPost @@ -132,7 +135,7 @@ type Model struct { onLoadDMs func(privateKey string) ([]FeedPost, error) onLoadReplies func(eventID string) ([]FeedPost, error) onInitQR func() (string, error) - onCheckQR func() (string, error) + onCheckQR func() (string, *nip46.ProfileMetadata, error) onLoadRelays func() ([]string, error) onSaveRelays func(relays []string) error hasKey func() bool @@ -173,7 +176,7 @@ func (m *Model) SetCallbacks( onPost func(message string) error, onLoadFeed func() ([]FeedPost, error), onInitQR func() (string, error), - onCheckQR func() (string, error), + onCheckQR func() (string, *nip46.ProfileMetadata, error), onLoadRelays func() ([]string, error), onSaveRelays func(relays []string) error, ) { @@ -313,10 +316,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case qrSuccessMsg: - m.publicKey = string(msg) + m.publicKey = msg.pubkey m.loggedIn = true m.screen = ScreenHome - m.message = "Logged in via Amber!" + // Surface the kind-0 profile on the home screen. The + // signer-published name wins over the raw pubkey prefix + // because users recognise themselves by name, not by + // 16 hex chars. If the kind-0 fetch failed (msg.profile + // is nil — many users haven't set a profile), fall back + // to a friendly "Logged in" message and let viewHome + // render the npub. + if msg.profile != nil && msg.profile.Name != "" { + m.currentProfileName = msg.profile.Name + m.message = fmt.Sprintf("Logged in as %s", msg.profile.Name) + } else { + m.message = "Logged in via Amber!" + } + if msg.profile != nil { + m.currentProfileAbout = msg.profile.About + m.currentProfileNIP05 = msg.profile.NIP05 + } m.messageStyle = successStyle return m, nil @@ -559,7 +578,10 @@ func (m Model) handleLoginEnter() (tea.Model, tea.Cmd) { return m, nil } -type qrSuccessMsg string +type qrSuccessMsg struct { + pubkey string + profile *nip46.ProfileMetadata // may be nil if kind-0 fetch failed +} func (m Model) initQR() tea.Msg { if m.onInitQR != nil { @@ -626,9 +648,9 @@ type qrGeneratedMsg struct { func (m Model) checkQRConnection() tea.Msg { if m.onCheckQR != nil { - pubkey, err := m.onCheckQR() + pubkey, profile, err := m.onCheckQR() if err == nil && pubkey != "" { - return qrSuccessMsg(pubkey) + return qrSuccessMsg{pubkey: pubkey, profile: profile} } // No connection yet — return a retry message so the TUI // re-fires this check after a short delay instead of giving @@ -1334,7 +1356,7 @@ type Config struct { OnLoadDMs func(privateKey string) ([]FeedPost, error) OnLoadReplies func(eventID string) ([]FeedPost, error) OnInitQR func() (string, error) - OnCheckQR func() (string, error) + OnCheckQR func() (string, *nip46.ProfileMetadata, error) OnLoadRelays func() ([]string, error) OnSaveRelays func(relays []string) error // Profile callbacks diff --git a/tui/tui_qr_bug_test.go b/tui/tui_qr_bug_test.go index b27fc01..56a5a08 100644 --- a/tui/tui_qr_bug_test.go +++ b/tui/tui_qr_bug_test.go @@ -5,6 +5,7 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" + "hoot/nip46" ) // TestQRGeneratesAfterInitMessage verifies that the QR code renders correctly @@ -29,7 +30,7 @@ func TestQRGeneratesAfterInitMessage(t *testing.T) { func(string) error { return nil }, // onPost func() ([]FeedPost, error) { return nil, nil }, // onLoadFeed func() (string, error) { return fakeURI, nil }, // onInitQR - func() (string, error) { return "", nil }, // onCheckQR + func() (string, *nip46.ProfileMetadata, error) { return "", nil, nil }, // onCheckQR func() ([]string, error) { return nil, nil }, // onLoadRelays func([]string) error { return nil }, // onSaveRelays ) @@ -82,7 +83,7 @@ func TestQRGeneratesWhenSizeMessageArrivesAfterQRData(t *testing.T) { func(string) error { return nil }, func() ([]FeedPost, error) { return nil, nil }, func() (string, error) { return fakeURI, nil }, - func() (string, error) { return "", nil }, + func() (string, *nip46.ProfileMetadata, error) { return "", nil, nil }, func() ([]string, error) { return nil, nil }, func([]string) error { return nil }, ) diff --git a/tui/tui_qr_e2e_test.go b/tui/tui_qr_e2e_test.go index 200125e..53fe1bf 100644 --- a/tui/tui_qr_e2e_test.go +++ b/tui/tui_qr_e2e_test.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" + "hoot/nip46" ) // TestQRFlowsThroughLoginMenuEndToEnd drives the full path a real user @@ -30,7 +31,7 @@ func TestQRFlowsThroughLoginMenuEndToEnd(t *testing.T) { // we just resolve immediately — the test is about flow not latency. return fakeURI, nil }, - func() (string, error) { return "", nil }, + func() (string, *nip46.ProfileMetadata, error) { return "", nil, nil }, func() ([]string, error) { return nil, nil }, func([]string) error { return nil }, ) @@ -103,7 +104,7 @@ func TestQRFlowsThroughLoginMenuEndToEnd(t *testing.T) { func(string) error { return nil }, func() ([]FeedPost, error) { return nil, nil }, func() (string, error) { return fakeURI, nil }, - func() (string, error) { return "", nil }, + func() (string, *nip46.ProfileMetadata, error) { return "", nil, nil }, func() ([]string, error) { return nil, nil }, func([]string) error { return nil }, ) diff --git a/tui/tui_qr_layout_test.go b/tui/tui_qr_layout_test.go index e4fa74f..25d450a 100644 --- a/tui/tui_qr_layout_test.go +++ b/tui/tui_qr_layout_test.go @@ -6,6 +6,7 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" + "hoot/nip46" ) // TestQRViewLayout dumps the full View() at every reasonable size and @@ -32,7 +33,7 @@ func TestQRViewLayout(t *testing.T) { func(string) error { return nil }, func() ([]FeedPost, error) { return nil, nil }, func() (string, error) { return fakeURI, nil }, - func() (string, error) { return "", nil }, + func() (string, *nip46.ProfileMetadata, error) { return "", nil, nil }, func() ([]string, error) { return nil, nil }, func([]string) error { return nil }, ) diff --git a/tui/tui_qr_width_test.go b/tui/tui_qr_width_test.go index 42cee85..94d9d3e 100644 --- a/tui/tui_qr_width_test.go +++ b/tui/tui_qr_width_test.go @@ -5,6 +5,7 @@ import ( "testing" tea "github.com/charmbracelet/bubbletea" + "hoot/nip46" "github.com/mattn/go-runewidth" ) @@ -26,7 +27,7 @@ func TestQRVisualFitsTerminalWidth(t *testing.T) { func(string) error { return nil }, func() ([]FeedPost, error) { return nil, nil }, func() (string, error) { return fakeURI, nil }, - func() (string, error) { return "", nil }, + func() (string, *nip46.ProfileMetadata, error) { return "", nil, nil }, func() ([]string, error) { return nil, nil }, func([]string) error { return nil }, )