From 7712436b08e58f9885b62ac7863369e959ae3e9a Mon Sep 17 00:00:00 2001 From: Andre Chang Date: Tue, 15 Sep 2026 20:23:07 -0400 Subject: [PATCH] feat(tui): add relay selection to the login screen The login menu now has a "Configure relays" option so relays can be reviewed, added, and removed before signing in. This matters most for QR (NIP-46) pairing, which dials the configured relay set. Also wires up the relay screen's add/delete/navigate controls ('a'/'d' keys, list navigation, Enter-to-save), which existed in the UI but were never connected to any key handling. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 + README.md | 1 + tui/tui.go | 131 +++++++++++++++++++++++++++++---- tui/tui_relay_prelogin_test.go | 93 +++++++++++++++++++++++ 4 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 tui/tui_relay_prelogin_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9cafa..ecb5671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Added a "Configure relays" option to the TUI login menu so relays can be reviewed, added, and removed before signing in — matters most for QR (NIP-46) pairing, which uses the configured relay set. Also wired up the relay screen's add/delete/navigate controls, which were present in the UI but not actually connected to any key handling. + ## [0.3.0] - 2026-09-14 Eight PRs since v0.2.0, all bug fixes around the NIP-46 QR login flow and a broader codebase review. diff --git a/README.md b/README.md index c1e723c..9f17f14 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ hoot The TUI provides: - **Login options**: Enter nsec directly, use saved profile, or scan QR code (NIP-46) +- **Configure relays before logging in**: The login menu has a "Configure relays" option so you can add/remove relays before signing in — useful since QR (NIP-46) pairing uses these relays - **Multi-profile support**: Save and switch between multiple Nostr accounts - **Post composer**: Write and publish notes - **Feed viewer**: Browse recent posts from the network diff --git a/tui/tui.go b/tui/tui.go index c37eec4..2c06b25 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -230,9 +230,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "esc": + if m.screen == ScreenRelays && m.addingRelay { + // Cancel just the "new relay" sub-form, not the whole screen. + m.addingRelay = false + m.textInput.Reset() + m.message = "" + return m, nil + } if m.screen == ScreenLogin { return m, tea.Quit } + if m.screen == ScreenRelays && !m.loggedIn { + // Relays were opened from the login menu (no session yet), + // so there is no Home screen to return to. + m.screen = ScreenLogin + m.cursor = 0 + m.message = "" + return m, nil + } // Go back to home from other screens m.screen = ScreenHome m.message = "" @@ -259,6 +274,37 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + case "a": + if m.screen == ScreenRelays && !m.addingRelay { + m.addingRelay = true + m.textInput.Placeholder = "wss://relay.example.com" + m.textInput.EchoMode = textinput.EchoNormal + m.textInput.Reset() + m.textInput.Focus() + m.message = "" + return m, nil + } + + case "d": + if m.screen == ScreenRelays && !m.addingRelay && len(m.relays) > 0 { + idx := m.selectedRelay + removed := m.relays[idx] + m.relays = append(m.relays[:idx], m.relays[idx+1:]...) + if m.selectedRelay >= len(m.relays) && m.selectedRelay > 0 { + m.selectedRelay-- + } + if m.onSaveRelays != nil { + if err := m.onSaveRelays(m.relays); err != nil { + m.message = fmt.Sprintf("Failed to save relays: %v", err) + m.messageStyle = errorStyle + return m, nil + } + } + m.message = fmt.Sprintf("Removed %s", removed) + m.messageStyle = successStyle + return m, nil + } + case "enter": return m.handleEnter() @@ -276,6 +322,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.replyScroll > 0 { m.replyScroll-- } + case ScreenRelays: + if !m.addingRelay && m.selectedRelay > 0 { + m.selectedRelay-- + } default: if m.cursor > 0 { m.cursor-- @@ -296,15 +346,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.replyScroll < len(m.replyPosts)-4 { m.replyScroll++ } + case ScreenRelays: + if !m.addingRelay && m.selectedRelay < len(m.relays)-1 { + m.selectedRelay++ + } case ScreenLogin: // Special handling for login menu - hasKey := m.hasKey != nil && m.hasKey() - limit := 0 - if !hasKey { - limit = 1 // 0=Nsec, 1=QR - } - if m.cursor < limit { - m.cursor++ + hasProfiles := len(m.profiles) > 0 + inTextEntry := m.addingProfile || m.cursor == 200 + if !inTextEntry { + limit := 2 // Create/Login option, Scan QR, Configure Relays + if hasProfiles { + limit = len(m.profiles) + 2 // profiles..., + Add profile, Scan QR, Configure Relays + } + if m.cursor < limit { + m.cursor++ + } } case ScreenHome: if m.cursor < 7 { @@ -444,6 +501,8 @@ func (m Model) handleEnter() (tea.Model, tea.Cmd) { return m.handlePostEnter() case ScreenReplies: return m.handleRepliesEnter() + case ScreenRelays: + return m.handleRelaysEnter() } return m, nil } @@ -483,6 +542,7 @@ func (m Model) handleLoginEnter() (tea.Model, tea.Cmd) { default: // Profile list selection addIdx := len(m.profiles) qrIdx := len(m.profiles) + 1 + relaysIdx := len(m.profiles) + 2 if m.cursor < len(m.profiles) { // Selected a profile - go to password entry @@ -506,6 +566,8 @@ func (m Model) handleLoginEnter() (tea.Model, tea.Cmd) { m.qrReady = false m.message = "Generating QR code..." return m, m.initQR + } else if m.cursor == relaysIdx { + m.openRelaysFromLogin() } } } else { @@ -584,12 +646,32 @@ func (m Model) handleLoginEnter() (tea.Model, tea.Cmd) { m.qrReady = false m.message = "Generating QR code..." return m, m.initQR + + case 2: + // Configure Relays selected + m.openRelaysFromLogin() } } return m, nil } +// openRelaysFromLogin switches to the relay settings screen from the login +// menu, before any session exists. It's factored out because both the +// no-profiles and has-profiles login menus offer a "Configure Relays" entry. +func (m *Model) openRelaysFromLogin() { + m.screen = ScreenRelays + m.addingRelay = false + m.selectedRelay = 0 + m.message = "" + if m.onLoadRelays != nil { + relays, err := m.onLoadRelays() + if err == nil { + m.relays = relays + } + } +} + type qrSuccessMsg struct { pubkey string profile *nip46.ProfileMetadata // may be nil if kind-0 fetch failed @@ -790,19 +872,21 @@ func (m Model) handleRelaysEnter() (tea.Model, tea.Cmd) { } m.relays = append(m.relays, newRelay) if m.onSaveRelays != nil { - m.onSaveRelays(m.relays) + if err := m.onSaveRelays(m.relays); err != nil { + m.message = fmt.Sprintf("Failed to save relay: %v", err) + m.messageStyle = errorStyle + m.addingRelay = false + m.textInput.Reset() + return m, nil + } } m.message = "Relay added" m.messageStyle = successStyle } m.addingRelay = false m.textInput.Reset() - } else { - // Toggle add mode? - // Or maybe Enter on "Add Relay" button? - // Simplify: 'a' to add, 'd' to delete. - // Enter does nothing unless we have a menu. } + // Enter does nothing while browsing the list — 'a' adds, 'd' deletes. return m, nil } @@ -973,6 +1057,15 @@ func (m Model) viewLogin() string { b.WriteString(menuStyle.Render(" 📱 Scan QR with Amber")) } b.WriteString("\n") + + // Configure Relays option + relaysIdx := len(m.profiles) + 2 + if m.cursor == relaysIdx { + b.WriteString(selectedStyle.Render("→ 🔗 Configure relays")) + } else { + b.WriteString(menuStyle.Render(" 🔗 Configure relays")) + } + b.WriteString("\n") } } else { // No profiles or adding new profile @@ -1003,6 +1096,7 @@ func (m Model) viewLogin() string { options := []string{ "Create new profile", "Scan QR with Amber (NIP-46)", + "🔗 Configure relays", } for i, opt := range options { @@ -1348,6 +1442,11 @@ func (m Model) viewRelays() string { b.WriteString(titleStyle.Render("🔗 Relay Settings")) b.WriteString("\n\n") + if !m.loggedIn { + b.WriteString(menuStyle.Render("Choose which relays hoot uses to publish, read, and pair via QR.")) + b.WriteString("\n\n") + } + if m.addingRelay { b.WriteString(menuStyle.Render("Enter new relay URL:")) b.WriteString("\n") @@ -1367,7 +1466,11 @@ func (m Model) viewRelays() string { } } b.WriteString("\n") - b.WriteString(menuStyle.Render("[a] Add Relay [d] Delete Selected [esc] Back")) + backLabel := "Back to Home" + if !m.loggedIn { + backLabel = "Back to Login" + } + b.WriteString(menuStyle.Render(fmt.Sprintf("[a] Add Relay [d] Delete Selected [esc] %s", backLabel))) } return b.String() diff --git a/tui/tui_relay_prelogin_test.go b/tui/tui_relay_prelogin_test.go new file mode 100644 index 0000000..d7c9ca0 --- /dev/null +++ b/tui/tui_relay_prelogin_test.go @@ -0,0 +1,93 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "hoot/nip46" +) + +// TestConfigureRelaysBeforeLogin verifies that the login menu offers a +// "Configure relays" entry, that selecting it opens the relay screen with +// the persisted relay list preloaded, that adding/removing a relay calls +// through to the save callback, and that Esc returns to the login screen +// (not Home, since no session exists yet). +func TestConfigureRelaysBeforeLogin(t *testing.T) { + savedRelays := []string{"wss://relay.damus.io"} + var lastSaved []string + + m := NewModel() + m.SetCallbacks( + func() bool { return false }, + func(string) (string, string, error) { return "", "", nil }, + func() error { return nil }, + func(string, string, bool) (string, error) { return "", nil }, + func(string) error { return nil }, + func() ([]FeedPost, error) { return nil, nil }, + func() (string, error) { return "", nil }, + func() (string, *nip46.ProfileMetadata, error) { return "", nil, nil }, + func() ([]string, error) { return savedRelays, nil }, + func(relays []string) error { lastSaved = relays; return nil }, + ) + + // No profiles yet: menu is [Create new profile, Scan QR, Configure relays]. + if m.loggedIn { + t.Fatalf("expected fresh model to not be logged in") + } + view := m.View() + if !strings.Contains(view, "Configure relays") { + t.Fatalf("login screen missing 'Configure relays' option:\n%s", view) + } + + // Move cursor down twice to land on "Configure relays" (index 2). + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(Model) + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = updated.(Model) + if m.cursor != 2 { + t.Fatalf("expected cursor at 2 after two down-presses, got %d", m.cursor) + } + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if m.screen != ScreenRelays { + t.Fatalf("expected ScreenRelays after selecting Configure relays, got %v", m.screen) + } + if len(m.relays) != 1 || m.relays[0] != "wss://relay.damus.io" { + t.Fatalf("expected preloaded relay list, got %v", m.relays) + } + + // Add a relay. + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) + m = updated.(Model) + if !m.addingRelay { + t.Fatalf("expected addingRelay=true after pressing 'a'") + } + for _, r := range "nos.lol" { + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + m = updated.(Model) + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if len(m.relays) != 2 || m.relays[1] != "wss://nos.lol" { + t.Fatalf("expected relay to be added with wss:// prefix, got %v", m.relays) + } + if len(lastSaved) != 2 { + t.Fatalf("expected onSaveRelays to be called with 2 relays, got %v", lastSaved) + } + + // Delete the selected (first) relay. + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + m = updated.(Model) + if len(m.relays) != 1 || m.relays[0] != "wss://nos.lol" { + t.Fatalf("expected first relay removed, got %v", m.relays) + } + + // Esc should return to the login screen, not Home, since no one is logged in. + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(Model) + if m.screen != ScreenLogin { + t.Fatalf("expected Esc to return to ScreenLogin pre-login, got %v", m.screen) + } +}