Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions hoot.go
Original file line number Diff line number Diff line change
Expand Up @@ -1465,26 +1465,51 @@ 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.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
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) {
Expand Down
130 changes: 130 additions & 0 deletions nip46/nip46.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
108 changes: 108 additions & 0 deletions nip46/profile_test.go
Original file line number Diff line number Diff line change
@@ -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", "<b>alice</b>", 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)
}
})
}
}
Loading
Loading