Skip to content
Draft
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
131 changes: 117 additions & 14 deletions tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand All @@ -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()

Expand All @@ -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--
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand All @@ -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()
Expand Down
93 changes: 93 additions & 0 deletions tui/tui_relay_prelogin_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading