gotextlog

gotextlog
git clone https://git.ryansepassi.com/git/gotextlog.git
Log | Files | Refs | README

model.go (32074B)


      1 package tui
      2 
      3 import (
      4 	"context"
      5 	"errors"
      6 	"fmt"
      7 	"strings"
      8 	"time"
      9 	"unicode/utf8"
     10 
     11 	"github.com/charmbracelet/bubbles/spinner"
     12 	tea "github.com/charmbracelet/bubbletea"
     13 
     14 	"git.ryansepassi.com/git/gotextlog.git/internal/config"
     15 	"git.ryansepassi.com/git/gotextlog.git/internal/domain"
     16 	"git.ryansepassi.com/git/gotextlog.git/internal/sshkey"
     17 	"git.ryansepassi.com/git/gotextlog.git/internal/textlog"
     18 )
     19 
     20 type screenKind string
     21 
     22 const (
     23 	screenFeed     screenKind = "feed"
     24 	screenSearch   screenKind = "search"
     25 	screenPost     screenKind = "post"
     26 	screenProfile  screenKind = "profile"
     27 	screenTag      screenKind = "tag"
     28 	screenLive     screenKind = "live"
     29 	screenCompose  screenKind = "compose"
     30 	screenLogin    screenKind = "login"
     31 	screenAccount  screenKind = "account"
     32 	screenSettings screenKind = "settings"
     33 	screenHelp     screenKind = "help"
     34 )
     35 
     36 type screen struct {
     37 	serial                              int
     38 	kind                                screenKind
     39 	feed                                string
     40 	id                                  int
     41 	handle, tag, query, input, phase    string
     42 	loading                             bool
     43 	err                                 string
     44 	posts, notes                        []textlog.Post
     45 	entries                             []ActivityEntry
     46 	depths, more                        []int
     47 	thread                              []domain.ThreadItem
     48 	folded                              map[int]bool
     49 	selected, target, page              int
     50 	cursors                             []string
     51 	nextCursor                          string
     52 	profile                             *textlog.Profile
     53 	users, following, followers, blocks []textlog.UserReference
     54 	tags, followingTags                 []textlog.TagReference
     55 	replies                             []textlog.Reply
     56 	keys                                []sshkey.Key
     57 	activeTab                           int
     58 	parent, edit                        *textlog.Post
     59 	preview                             bool
     60 	menu                                []string
     61 	menuSelected                        int
     62 	paused                              bool
     63 	streamPosts                         <-chan textlog.Post
     64 	streamErrors                        <-chan error
     65 	streamCancel                        context.CancelFunc
     66 	streamState                         string
     67 }
     68 
     69 type Options struct {
     70 	SaveConfig func(config.Config) error
     71 	OpenURL    func(string) error
     72 	Context    context.Context
     73 }
     74 
     75 type Model struct {
     76 	backend                   Backend
     77 	config                    config.Config
     78 	options                   Options
     79 	stack                     []*screen
     80 	me                        *textlog.CurrentUser
     81 	status                    string
     82 	statusError               bool
     83 	width, height, nextSerial int
     84 	spinner                   spinner.Model
     85 }
     86 
     87 type loadedMsg struct {
     88 	serial int
     89 	op     Operation
     90 	result Result
     91 	err    error
     92 }
     93 type savedMsg struct {
     94 	cfg    config.Config
     95 	status string
     96 	err    error
     97 }
     98 type openedMsg struct{ err error }
     99 type streamStartedMsg struct {
    100 	serial int
    101 	posts  <-chan textlog.Post
    102 	errs   <-chan error
    103 	cancel context.CancelFunc
    104 }
    105 type liveMsg struct {
    106 	serial int
    107 	post   *textlog.Post
    108 	err    error
    109 	closed bool
    110 }
    111 type reconnectMsg struct{ serial int }
    112 
    113 func New(backend Backend, cfg config.Config, options Options) Model {
    114 	if options.Context == nil {
    115 		options.Context = context.Background()
    116 	}
    117 	if options.SaveConfig == nil {
    118 		options.SaveConfig = func(c config.Config) error { return config.Save(c, nil) }
    119 	}
    120 	if options.OpenURL == nil {
    121 		options.OpenURL = openBrowser
    122 	}
    123 	m := Model{backend: backend, config: cfg, options: options, width: 80, height: 24, nextSerial: 1,
    124 		spinner: spinner.New(spinner.WithSpinner(spinner.MiniDot))}
    125 	s := m.newScreen(screenFeed)
    126 	s.feed = string(config.FeedLatest)
    127 	if cfg.Token != "" && cfg.LastTab != "" {
    128 		s.feed = string(cfg.LastTab)
    129 	}
    130 	s.loading, s.cursors = true, []string{""}
    131 	m.stack = []*screen{s}
    132 	return m
    133 }
    134 
    135 func (m *Model) newScreen(kind screenKind) *screen {
    136 	s := &screen{serial: m.nextSerial, kind: kind, target: -1, streamState: "connecting"}
    137 	m.nextSerial++
    138 	return s
    139 }
    140 func (m Model) current() *screen { return m.stack[len(m.stack)-1] }
    141 func (m *Model) find(serial int) *screen {
    142 	for _, s := range m.stack {
    143 		if s.serial == serial {
    144 			return s
    145 		}
    146 	}
    147 	return nil
    148 }
    149 
    150 func (m Model) Init() tea.Cmd {
    151 	cmds := []tea.Cmd{m.command(m.current(), Operation{Kind: OpFeed, Feed: m.current().feed}), m.spinner.Tick}
    152 	if m.config.Token != "" {
    153 		cmds = append(cmds, m.command(nil, Operation{Kind: OpMe}))
    154 	}
    155 	return tea.Batch(cmds...)
    156 }
    157 func (m Model) command(s *screen, op Operation) tea.Cmd {
    158 	serial := 0
    159 	if s != nil {
    160 		serial = s.serial
    161 	}
    162 	return func() tea.Msg {
    163 		result, err := m.backend.Execute(m.options.Context, op)
    164 		return loadedMsg{serial, op, result, err}
    165 	}
    166 }
    167 
    168 func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
    169 	switch msg := message.(type) {
    170 	case tea.WindowSizeMsg:
    171 		m.width, m.height = msg.Width, msg.Height
    172 	case spinner.TickMsg:
    173 		var cmd tea.Cmd
    174 		m.spinner, cmd = m.spinner.Update(msg)
    175 		return m, cmd
    176 	case loadedMsg:
    177 		return m.applyLoaded(msg)
    178 	case savedMsg:
    179 		if msg.err != nil {
    180 			m.setStatus(msg.err.Error(), true)
    181 		} else {
    182 			m.config = msg.cfg
    183 			if backend, ok := m.backend.(interface{ Configure(config.Config) }); ok {
    184 				backend.Configure(msg.cfg)
    185 			}
    186 			if msg.status != "" {
    187 				m.setStatus(msg.status, false)
    188 			}
    189 		}
    190 	case openedMsg:
    191 		if msg.err != nil {
    192 			m.setStatus(msg.err.Error(), true)
    193 		}
    194 	case streamStartedMsg:
    195 		s := m.find(msg.serial)
    196 		if s == nil {
    197 			msg.cancel()
    198 			break
    199 		}
    200 		s.streamPosts, s.streamErrors, s.streamCancel, s.streamState = msg.posts, msg.errs, msg.cancel, "live"
    201 		return m, waitLive(s)
    202 	case liveMsg:
    203 		s := m.find(msg.serial)
    204 		if s == nil {
    205 			break
    206 		}
    207 		if s.paused {
    208 			s.streamState = "paused"
    209 			return m, nil
    210 		}
    211 		if msg.post != nil {
    212 			s.posts = prependUnique(s.posts, *msg.post, 100)
    213 			return m, waitLive(s)
    214 		}
    215 		s.streamState = "disconnected ยท retrying"
    216 		if !s.paused {
    217 			return m, tea.Tick(time.Second, func(time.Time) tea.Msg { return reconnectMsg{s.serial} })
    218 		}
    219 	case reconnectMsg:
    220 		if s := m.find(msg.serial); s != nil && !s.paused {
    221 			return m, m.startStream(s)
    222 		}
    223 	case tea.KeyMsg:
    224 		return m.key(msg)
    225 	}
    226 	return m, nil
    227 }
    228 
    229 func (m Model) applyLoaded(msg loadedMsg) (tea.Model, tea.Cmd) {
    230 	if msg.serial == 0 && msg.op.Kind == OpMe {
    231 		if msg.err == nil {
    232 			m.me = msg.result.Me
    233 		} else {
    234 			m.setStatus(errorText(msg.err), true)
    235 		}
    236 		return m, nil
    237 	}
    238 	s := m.find(msg.serial)
    239 	if s == nil {
    240 		return m, nil
    241 	}
    242 	s.loading = false
    243 	if msg.err != nil {
    244 		s.err = errorText(msg.err)
    245 		return m, nil
    246 	}
    247 	s.err = ""
    248 	switch msg.op.Kind {
    249 	case OpFeed, OpSearch:
    250 		s.posts, s.entries, s.nextCursor = msg.result.Posts, msg.result.Entries, msg.result.NextCursor
    251 		if len(s.entries) > 0 {
    252 			s.posts = make([]textlog.Post, len(s.entries))
    253 			for i := range s.entries {
    254 				s.posts[i] = s.entries[i].Post
    255 			}
    256 		}
    257 		s.selected, s.target = 0, -1
    258 	case OpThread:
    259 		m.setThread(s, msg.result)
    260 	case OpProfile:
    261 		s.profile, s.notes, s.posts, s.replies = msg.result.Profile, msg.result.Posts, msg.result.Posts, msg.result.Replies
    262 		s.following, s.followingTags, s.followers, s.blocks = msg.result.Following, msg.result.FollowingTags, msg.result.Followers, msg.result.Blocks
    263 		s.users, s.tags = s.following, s.followingTags
    264 	case OpTag:
    265 		s.posts, s.users = msg.result.Posts, msg.result.Followers
    266 		if len(msg.result.Tags) > 0 {
    267 			s.tags = []textlog.TagReference{msg.result.Tags[0]}
    268 		}
    269 	case OpRequestCode:
    270 		s.phase, s.query, s.input = "code", msg.op.Email, ""
    271 		m.setStatus("Check your email for the six-digit code", false)
    272 	case OpListKeys:
    273 		s.phase, s.keys, s.selected = "key", msg.result.Keys, 0
    274 		if len(s.keys) == 0 {
    275 			m.setStatus("No Ed25519 SSH public keys found in ~/.ssh", true)
    276 		}
    277 	case OpVerifyCode, OpKeyLogin:
    278 		if msg.result.Session != nil {
    279 			m.me, m.config.Token = msg.result.Me, msg.result.Session.Token
    280 			m.pop()
    281 			return m, m.saveCmd(m.config, "Signed in as @"+msg.result.Session.User.Handle)
    282 		}
    283 	case OpRevoke:
    284 		m.me, m.config.Token = nil, ""
    285 		m.pop()
    286 		return m, m.saveCmd(m.config, "Signed out")
    287 	case OpUpdateBio:
    288 		m.me, s.phase = msg.result.Me, ""
    289 		m.setStatus("Bio updated", false)
    290 	case OpCreatePost:
    291 		if msg.result.Post != nil {
    292 			m.setStatus("Note posted", false)
    293 			m.pop()
    294 			next := m.newScreen(screenPost)
    295 			next.id, next.loading = msg.result.Post.ID, true
    296 			m.stack = append(m.stack, next)
    297 			return m, m.command(next, Operation{Kind: OpThread, ID: next.id})
    298 		}
    299 	case OpEditPost:
    300 		m.setStatus("Note updated", false)
    301 		m.pop()
    302 	case OpDeletePost:
    303 		m.setStatus("Note deleted", false)
    304 		m.pop()
    305 	case OpFollow:
    306 		verb := "Following @"
    307 		if !msg.op.Enabled {
    308 			verb = "Unfollowed @"
    309 		}
    310 		m.setStatus(verb+msg.op.Handle, false)
    311 	case OpBlock:
    312 		verb := "Blocked @"
    313 		if !msg.op.Enabled {
    314 			verb = "Unblocked @"
    315 		}
    316 		m.setStatus(verb+msg.op.Handle, false)
    317 	case OpReport:
    318 		m.setStatus("Report submitted", false)
    319 		s.menu = nil
    320 	}
    321 	return m, nil
    322 }
    323 
    324 func (m Model) key(key tea.KeyMsg) (tea.Model, tea.Cmd) {
    325 	s := m.current()
    326 	if len(s.menu) > 0 {
    327 		return m.menuKey(key)
    328 	}
    329 	if s.kind == screenCompose {
    330 		return m.composeKey(key)
    331 	}
    332 	if s.kind == screenLogin {
    333 		return m.loginKey(key)
    334 	}
    335 	if s.kind == screenSearch && s.phase == "input" {
    336 		return m.searchKey(key)
    337 	}
    338 	if s.kind == screenAccount && s.phase == "bio" {
    339 		return m.bioKey(key)
    340 	}
    341 	if s.kind == screenSettings && s.phase == "url" {
    342 		return m.urlKey(key)
    343 	}
    344 	if s.loading {
    345 		if key.String() == "q" || key.Type == tea.KeyEsc {
    346 			m.pop()
    347 		}
    348 		return m, nil
    349 	}
    350 	if cmd, ok := m.screenKey(s, key); ok {
    351 		return m, cmd
    352 	}
    353 	return m.globalKey(key)
    354 }
    355 
    356 func (m *Model) screenKey(s *screen, key tea.KeyMsg) (tea.Cmd, bool) {
    357 	k := key.String()
    358 	switch s.kind {
    359 	case screenFeed, screenSearch:
    360 		if cmd, ok := m.listKey(s, key); ok {
    361 			return cmd, true
    362 		}
    363 		if k == "R" {
    364 			s.loading = true
    365 			return m.reloadList(s), true
    366 		}
    367 		if k == "n" && s.nextCursor != "" {
    368 			s.page++
    369 			s.cursors = append(s.cursors, s.nextCursor)
    370 			s.loading = true
    371 			return m.reloadList(s), true
    372 		}
    373 		if k == "p" && s.page > 0 {
    374 			s.page--
    375 			s.loading = true
    376 			return m.reloadList(s), true
    377 		}
    378 	case screenPost:
    379 		if k == " " {
    380 			m.toggleThreadFold(s)
    381 			return nil, true
    382 		}
    383 		if cmd, ok := m.listKey(s, key); ok {
    384 			return cmd, true
    385 		}
    386 		p := selectedPost(s)
    387 		if p == nil {
    388 			return nil, false
    389 		}
    390 		switch k {
    391 		case "T":
    392 			if p.TopID != nil {
    393 				return m.openPost(*p.TopID), true
    394 			}
    395 		case "P":
    396 			if p.ParentID != nil {
    397 				return m.openPost(*p.ParentID), true
    398 			}
    399 		case "e":
    400 			if m.owns(*p) {
    401 				m.openCompose(nil, p)
    402 				return nil, true
    403 			}
    404 		case "d":
    405 			if m.owns(*p) {
    406 				s.menu = []string{"delete note", "cancel"}
    407 				return nil, true
    408 			}
    409 		case "!":
    410 			if !m.owns(*p) {
    411 				s.menu = []string{"report harassment", "report spam", "report impersonation", "report other", "cancel"}
    412 				return nil, true
    413 			}
    414 		case "f", "F":
    415 			return m.action(s, Operation{Kind: OpFollow, Handle: p.Author.Handle, Enabled: k == "f"}), true
    416 		case "b", "B":
    417 			return m.action(s, Operation{Kind: OpBlock, Handle: p.Author.Handle, Enabled: k == "b"}), true
    418 		}
    419 	case screenProfile:
    420 		if k == "tab" || k == "right" {
    421 			s.activeTab = (s.activeTab + 1) % m.profileTabCount(s)
    422 			m.setProfileTab(s)
    423 			return nil, true
    424 		}
    425 		if k == "shift+tab" || k == "left" {
    426 			s.activeTab = (s.activeTab + m.profileTabCount(s) - 1) % m.profileTabCount(s)
    427 			m.setProfileTab(s)
    428 			return nil, true
    429 		}
    430 		if s.activeTab < 2 {
    431 			if cmd, ok := m.listKey(s, key); ok {
    432 				return cmd, true
    433 			}
    434 		} else if cmd, ok := m.referenceKey(s, key); ok {
    435 			return cmd, true
    436 		}
    437 		if k == "o" && s.profile != nil {
    438 			return m.openURL(s.profile.URL), true
    439 		}
    440 		if k == "f" || k == "F" {
    441 			return m.action(s, Operation{Kind: OpFollow, Handle: s.handle, Enabled: k == "f"}), true
    442 		}
    443 		if k == "b" || k == "B" {
    444 			return m.action(s, Operation{Kind: OpBlock, Handle: s.handle, Enabled: k == "b"}), true
    445 		}
    446 	case screenTag:
    447 		if k == "tab" || k == "shift+tab" || k == "left" || k == "right" {
    448 			s.activeTab = 1 - s.activeTab
    449 			s.selected = 0
    450 			return nil, true
    451 		}
    452 		if s.activeTab == 0 {
    453 			if cmd, ok := m.listKey(s, key); ok {
    454 				return cmd, true
    455 			}
    456 		} else if cmd, ok := m.referenceKey(s, key); ok {
    457 			return cmd, true
    458 		}
    459 	case screenLive:
    460 		if k == " " {
    461 			s.paused = !s.paused
    462 			if s.paused {
    463 				if s.streamCancel != nil {
    464 					s.streamCancel()
    465 				}
    466 				s.streamState = "paused"
    467 				return nil, true
    468 			}
    469 			s.streamState = "connecting"
    470 			return m.startStream(s), true
    471 		}
    472 		if cmd, ok := m.listKey(s, key); ok {
    473 			return cmd, true
    474 		}
    475 	case screenAccount:
    476 		if m.me == nil {
    477 			return nil, false
    478 		}
    479 		switch k {
    480 		case "e":
    481 			s.phase, s.input = "bio", m.me.Bio
    482 			return nil, true
    483 		case "s":
    484 			s.loading = true
    485 			return m.command(s, Operation{Kind: OpRevoke}), true
    486 		case "u":
    487 			return m.openProfile(m.me.Handle), true
    488 		case ",":
    489 			m.stack = append(m.stack, m.newScreen(screenSettings))
    490 			return nil, true
    491 		}
    492 	case screenSettings:
    493 		switch k {
    494 		case "j", "down":
    495 			s.menuSelected = min(3, s.menuSelected+1)
    496 			return nil, true
    497 		case "k", "up":
    498 			s.menuSelected = max(0, s.menuSelected-1)
    499 			return nil, true
    500 		case "t":
    501 			s.menuSelected = 0
    502 		case "u":
    503 			s.menuSelected = 1
    504 		case "o":
    505 			s.menuSelected = 2
    506 		case "enter":
    507 		default:
    508 			return nil, false
    509 		}
    510 		switch s.menuSelected {
    511 		case 0:
    512 			m.cycleTheme()
    513 			return m.saveCmd(m.config, "Theme updated"), true
    514 		case 1:
    515 			s.phase, s.input = "url", m.config.BaseURL
    516 			return nil, true
    517 		case 2:
    518 			return m.openURL(m.config.BaseURL), true
    519 		default:
    520 			m.pop()
    521 			return nil, true
    522 		}
    523 	}
    524 	return nil, false
    525 }
    526 
    527 func (m *Model) listKey(s *screen, key tea.KeyMsg) (tea.Cmd, bool) {
    528 	switch key.String() {
    529 	case "j", "down":
    530 		s.selected, s.target = clamp(s.selected+1, len(s.posts)), -1
    531 		return nil, true
    532 	case "k", "up":
    533 		s.selected, s.target = clamp(s.selected-1, len(s.posts)), -1
    534 		return nil, true
    535 	case "g":
    536 		s.selected, s.target = 0, -1
    537 		return nil, true
    538 	case "G":
    539 		s.selected, s.target = max(0, len(s.posts)-1), -1
    540 		return nil, true
    541 	case "tab":
    542 		targets := targetsFor(selectedPost(s))
    543 		if len(targets) > 0 {
    544 			s.target = (s.target + 1) % len(targets)
    545 		}
    546 		return nil, true
    547 	case "shift+tab":
    548 		targets := targetsFor(selectedPost(s))
    549 		if len(targets) > 0 {
    550 			s.target--
    551 			if s.target < 0 {
    552 				s.target = len(targets) - 1
    553 			}
    554 		}
    555 		return nil, true
    556 	case "enter", "l", "right":
    557 		p := selectedPost(s)
    558 		if p == nil {
    559 			return nil, true
    560 		}
    561 		if s.target >= 0 {
    562 			return m.activate(*p, s.target), true
    563 		}
    564 		if p.ID < 0 {
    565 			return m.openProfile(p.Author.Handle), true
    566 		}
    567 		return m.openPost(p.ID), true
    568 	case "r":
    569 		p := selectedPost(s)
    570 		if p != nil {
    571 			if m.me == nil {
    572 				return m.openLogin(), true
    573 			}
    574 			m.openCompose(p, nil)
    575 		}
    576 		return nil, true
    577 	case "u":
    578 		p := selectedPost(s)
    579 		if p != nil {
    580 			return m.openProfile(p.Author.Handle), true
    581 		}
    582 		return nil, true
    583 	case "t":
    584 		p := selectedPost(s)
    585 		if p != nil && len(p.Tags) > 0 {
    586 			return m.openTag(p.Tags[0]), true
    587 		}
    588 		return nil, true
    589 	case "o":
    590 		p := selectedPost(s)
    591 		if p != nil {
    592 			return m.openURL(p.URL), true
    593 		}
    594 		return nil, true
    595 	}
    596 	return nil, false
    597 }
    598 
    599 func (m *Model) globalKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
    600 	k := key.String()
    601 	if k == "q" || k == "esc" || k == "h" || k == "left" {
    602 		if len(m.stack) > 1 {
    603 			m.pop()
    604 			return *m, nil
    605 		}
    606 		return *m, tea.Quit
    607 	}
    608 	switch k {
    609 	case "?":
    610 		m.stack = append(m.stack, m.newScreen(screenHelp))
    611 	case "/":
    612 		s := m.newScreen(screenSearch)
    613 		s.phase = "input"
    614 		m.stack = append(m.stack, s)
    615 	case "w":
    616 		if m.me == nil {
    617 			return *m, m.openLogin()
    618 		}
    619 		m.openCompose(nil, nil)
    620 	case "a":
    621 		if m.me == nil {
    622 			return *m, m.openLogin()
    623 		}
    624 		m.stack = append(m.stack, m.newScreen(screenAccount))
    625 	case "0":
    626 		if m.me != nil {
    627 			return *m, m.openProfile(m.me.Handle)
    628 		}
    629 	case "1":
    630 		if m.config.Token != "" {
    631 			return *m, m.openFeed("for-you")
    632 		}
    633 		return *m, m.openFeed("hot")
    634 	case "2":
    635 		if m.config.Token != "" {
    636 			return *m, m.openFeed("to-me")
    637 		}
    638 		return *m, m.openFeed("latest")
    639 	case "3":
    640 		if m.config.Token != "" {
    641 			return *m, m.openFeed("hot")
    642 		}
    643 		return *m, m.openLive()
    644 	case "4":
    645 		if m.config.Token != "" {
    646 			return *m, m.openFeed("latest")
    647 		}
    648 	case "5":
    649 		if m.config.Token != "" {
    650 			return *m, m.openLive()
    651 		}
    652 	}
    653 	return *m, nil
    654 }
    655 
    656 func (m *Model) composeKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
    657 	s := m.current()
    658 	k := key.String()
    659 	if s.preview {
    660 		switch k {
    661 		case "j", "down":
    662 			s.menuSelected = min(2, s.menuSelected+1)
    663 		case "k", "up":
    664 			s.menuSelected = max(0, s.menuSelected-1)
    665 		case "esc":
    666 			s.preview = false
    667 		case "enter":
    668 			if s.menuSelected == 0 {
    669 				op := Operation{Kind: OpCreatePost, Text: s.input}
    670 				if s.parent != nil {
    671 					op.ParentID = &s.parent.ID
    672 				}
    673 				if s.edit != nil {
    674 					op.Kind, op.ID = OpEditPost, s.edit.ID
    675 				}
    676 				s.loading = true
    677 				return *m, m.command(s, op)
    678 			} else if s.menuSelected == 1 {
    679 				s.preview = false
    680 			} else {
    681 				m.pop()
    682 			}
    683 		}
    684 		return *m, nil
    685 	}
    686 	if k == "esc" {
    687 		m.pop()
    688 		return *m, nil
    689 	}
    690 	if k == "ctrl+s" {
    691 		if problem := domain.ValidatePost(s.input); problem != "" {
    692 			m.setStatus(problem, true)
    693 		} else {
    694 			s.preview = true
    695 		}
    696 		return *m, nil
    697 	}
    698 	if key.Type == tea.KeyEnter {
    699 		if strings.Count(s.input, "\n") < 9 {
    700 			s.input += "\n"
    701 		}
    702 		return *m, nil
    703 	}
    704 	edit(&s.input, key, 280)
    705 	return *m, nil
    706 }
    707 func (m *Model) loginKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
    708 	s := m.current()
    709 	k := key.String()
    710 	if k == "tab" {
    711 		if s.phase == "email" || s.phase == "code" {
    712 			s.phase, s.input, s.err, s.loading = "key", "", "", true
    713 			return *m, m.command(s, Operation{Kind: OpListKeys})
    714 		}
    715 		s.phase, s.input, s.err = "email", "", ""
    716 		return *m, nil
    717 	}
    718 	if k == "esc" {
    719 		switch s.phase {
    720 		case "code":
    721 			s.phase, s.input = "email", ""
    722 		case "handle":
    723 			s.phase, s.input = "key", ""
    724 		default:
    725 			m.pop()
    726 		}
    727 		return *m, nil
    728 	}
    729 	if s.phase == "key" {
    730 		switch k {
    731 		case "j", "down":
    732 			s.selected = clamp(s.selected+1, len(s.keys))
    733 		case "k", "up":
    734 			s.selected = clamp(s.selected-1, len(s.keys))
    735 		case "enter":
    736 			if len(s.keys) == 0 {
    737 				m.setStatus("No Ed25519 SSH public keys found in ~/.ssh", true)
    738 				return *m, nil
    739 			}
    740 			s.phase, s.input = "handle", ""
    741 		}
    742 		return *m, nil
    743 	}
    744 	if key.Type == tea.KeyEnter {
    745 		if s.phase == "email" {
    746 			if !strings.Contains(s.input, "@") || !strings.Contains(s.input, ".") {
    747 				m.setStatus("Enter a valid email address", true)
    748 				return *m, nil
    749 			}
    750 			s.loading = true
    751 			return *m, m.command(s, Operation{Kind: OpRequestCode, Email: s.input})
    752 		}
    753 		if s.phase == "handle" {
    754 			if len(s.keys) == 0 {
    755 				m.setStatus("Select an Ed25519 SSH key", true)
    756 				return *m, nil
    757 			}
    758 			s.loading = true
    759 			return *m, m.command(s, Operation{Kind: OpKeyLogin, Key: s.keys[s.selected], Handle: strings.TrimSpace(s.input)})
    760 		}
    761 		validCode := len(s.input) == 6
    762 		for _, digit := range s.input {
    763 			validCode = validCode && digit >= '0' && digit <= '9'
    764 		}
    765 		if !validCode {
    766 			m.setStatus("Enter the six-digit code", true)
    767 			return *m, nil
    768 		}
    769 		s.loading = true
    770 		return *m, m.command(s, Operation{Kind: OpVerifyCode, Email: s.query, Code: s.input})
    771 	}
    772 	edit(&s.input, key, 320)
    773 	return *m, nil
    774 }
    775 func (m *Model) searchKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
    776 	s := m.current()
    777 	if key.String() == "esc" {
    778 		m.pop()
    779 		return *m, nil
    780 	}
    781 	if key.Type == tea.KeyEnter && strings.TrimSpace(s.input) != "" {
    782 		s.query, s.phase, s.loading = strings.TrimSpace(s.input), "results", true
    783 		s.cursors = []string{""}
    784 		return *m, m.command(s, Operation{Kind: OpSearch, Query: s.query})
    785 	}
    786 	edit(&s.input, key, 200)
    787 	return *m, nil
    788 }
    789 func (m *Model) bioKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
    790 	s := m.current()
    791 	if key.String() == "esc" {
    792 		s.phase = ""
    793 		return *m, nil
    794 	}
    795 	if key.String() == "ctrl+s" {
    796 		s.loading = true
    797 		return *m, m.command(s, Operation{Kind: OpUpdateBio, Text: s.input})
    798 	}
    799 	if key.Type == tea.KeyEnter {
    800 		s.input += "\n"
    801 	} else {
    802 		edit(&s.input, key, 1000)
    803 	}
    804 	return *m, nil
    805 }
    806 func (m *Model) urlKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
    807 	s := m.current()
    808 	if key.String() == "esc" {
    809 		s.phase = ""
    810 		return *m, nil
    811 	}
    812 	if key.Type == tea.KeyEnter {
    813 		value, err := config.NormalizeBaseURL(s.input)
    814 		if err != nil {
    815 			m.setStatus(err.Error(), true)
    816 			return *m, nil
    817 		}
    818 		m.config.BaseURL = value
    819 		s.phase = ""
    820 		return *m, m.saveCmd(m.config, "Instance updated")
    821 	}
    822 	edit(&s.input, key, 500)
    823 	return *m, nil
    824 }
    825 
    826 func (m *Model) menuKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
    827 	s := m.current()
    828 	switch key.String() {
    829 	case "j", "down":
    830 		s.menuSelected = (s.menuSelected + 1) % len(s.menu)
    831 	case "k", "up":
    832 		s.menuSelected = (s.menuSelected + len(s.menu) - 1) % len(s.menu)
    833 	case "q", "esc":
    834 		s.menu = nil
    835 	case "enter":
    836 		p := selectedPost(s)
    837 		if s.menu[s.menuSelected] == "cancel" {
    838 			s.menu = nil
    839 			return *m, nil
    840 		}
    841 		s.loading = true
    842 		if s.menu[s.menuSelected] == "delete note" {
    843 			s.menu = nil
    844 			return *m, m.command(s, Operation{Kind: OpDeletePost, ID: p.ID})
    845 		}
    846 		reasons := []textlog.ReportReason{textlog.ReportHarassment, textlog.ReportSpam, textlog.ReportImpersonation, textlog.ReportOther}
    847 		return *m, m.command(s, Operation{Kind: OpReport, ID: p.ID, Reason: reasons[s.menuSelected]})
    848 	}
    849 	return *m, nil
    850 }
    851 
    852 func (m *Model) reloadList(s *screen) tea.Cmd {
    853 	cursor := ""
    854 	if s.page < len(s.cursors) {
    855 		cursor = s.cursors[s.page]
    856 	}
    857 	if s.kind == screenSearch {
    858 		return m.command(s, Operation{Kind: OpSearch, Query: s.query, Cursor: cursor})
    859 	}
    860 	return m.command(s, Operation{Kind: OpFeed, Feed: s.feed, Cursor: cursor, Page: s.page})
    861 }
    862 func (m *Model) openFeed(feed string) tea.Cmd {
    863 	s := m.newScreen(screenFeed)
    864 	s.feed, s.loading, s.cursors = feed, true, []string{""}
    865 	m.stack[len(m.stack)-1] = s
    866 	load := m.command(s, Operation{Kind: OpFeed, Feed: feed})
    867 	if m.config.Token != "" && m.config.LastTab != config.FeedKind(feed) {
    868 		m.config.LastTab = config.FeedKind(feed)
    869 		return tea.Batch(load, m.saveCmd(m.config, ""))
    870 	}
    871 	return load
    872 }
    873 func (m *Model) openPost(id int) tea.Cmd {
    874 	s := m.newScreen(screenPost)
    875 	s.id, s.loading = id, true
    876 	m.stack = append(m.stack, s)
    877 	return m.command(s, Operation{Kind: OpThread, ID: id})
    878 }
    879 func (m *Model) openProfile(handle string) tea.Cmd {
    880 	s := m.newScreen(screenProfile)
    881 	s.handle, s.loading = handle, true
    882 	m.stack = append(m.stack, s)
    883 	return m.command(s, Operation{Kind: OpProfile, Handle: handle, Enabled: m.me != nil && strings.EqualFold(m.me.Handle, handle)})
    884 }
    885 func (m *Model) openTag(tag string) tea.Cmd {
    886 	s := m.newScreen(screenTag)
    887 	s.tag, s.loading = strings.TrimPrefix(tag, "#"), true
    888 	m.stack = append(m.stack, s)
    889 	return m.command(s, Operation{Kind: OpTag, Tag: s.tag})
    890 }
    891 func (m *Model) openLogin() tea.Cmd {
    892 	s := m.newScreen(screenLogin)
    893 	s.phase = "email"
    894 	m.stack = append(m.stack, s)
    895 	return nil
    896 }
    897 func (m *Model) openLive() tea.Cmd {
    898 	s := m.newScreen(screenLive)
    899 	m.stack = append(m.stack, s)
    900 	return m.startStream(s)
    901 }
    902 func (m *Model) openCompose(parent, edit *textlog.Post) {
    903 	s := m.newScreen(screenCompose)
    904 	s.parent, s.edit = parent, edit
    905 	if edit != nil {
    906 		s.input = edit.Body
    907 	}
    908 	m.stack = append(m.stack, s)
    909 }
    910 func (m *Model) pop() {
    911 	if len(m.stack) > 1 {
    912 		s := m.current()
    913 		if s.streamCancel != nil {
    914 			s.streamCancel()
    915 		}
    916 		m.stack = m.stack[:len(m.stack)-1]
    917 	}
    918 }
    919 
    920 func (m *Model) action(s *screen, op Operation) tea.Cmd {
    921 	if m.me == nil {
    922 		return m.openLogin()
    923 	}
    924 	s.loading = true
    925 	return m.command(s, op)
    926 }
    927 func (m Model) saveCmd(cfg config.Config, status string) tea.Cmd {
    928 	return func() tea.Msg { return savedMsg{cfg: cfg, status: status, err: m.options.SaveConfig(cfg)} }
    929 }
    930 func (m Model) openURL(url string) tea.Cmd {
    931 	return func() tea.Msg {
    932 		if url == "" {
    933 			return openedMsg{errors.New("no browser URL available")}
    934 		}
    935 		return openedMsg{m.options.OpenURL(url)}
    936 	}
    937 }
    938 func (m Model) startStream(s *screen) tea.Cmd {
    939 	return func() tea.Msg {
    940 		ctx, cancel := context.WithCancel(m.options.Context)
    941 		posts, errs := m.backend.Firehose(ctx)
    942 		return streamStartedMsg{s.serial, posts, errs, cancel}
    943 	}
    944 }
    945 func waitLive(s *screen) tea.Cmd {
    946 	return func() tea.Msg {
    947 		select {
    948 		case p, ok := <-s.streamPosts:
    949 			if !ok {
    950 				return liveMsg{serial: s.serial, closed: true}
    951 			}
    952 			return liveMsg{serial: s.serial, post: &p}
    953 		case err, ok := <-s.streamErrors:
    954 			if !ok {
    955 				return liveMsg{serial: s.serial, closed: true}
    956 			}
    957 			return liveMsg{serial: s.serial, err: err}
    958 		}
    959 	}
    960 }
    961 
    962 func (m *Model) setStatus(text string, isError bool) { m.status, m.statusError = text, isError }
    963 func (m Model) owns(p textlog.Post) bool {
    964 	return m.me != nil && strings.EqualFold(m.me.Handle, p.Author.Handle)
    965 }
    966 func (m *Model) cycleTheme() {
    967 	themes := []config.Theme{config.ThemeAuto, config.ThemeLight, config.ThemeDark}
    968 	for i, t := range themes {
    969 		if t == m.config.Theme {
    970 			m.config.Theme = themes[(i+1)%len(themes)]
    971 			return
    972 		}
    973 	}
    974 }
    975 func edit(value *string, key tea.KeyMsg, limit int) {
    976 	if key.Type == tea.KeyBackspace || key.Type == tea.KeyDelete {
    977 		if *value != "" {
    978 			_, n := utf8.DecodeLastRuneInString(*value)
    979 			*value = (*value)[:len(*value)-n]
    980 		}
    981 		return
    982 	}
    983 	if key.Type == tea.KeySpace && utf8.RuneCountInString(*value) < limit {
    984 		*value += " "
    985 		return
    986 	}
    987 	if key.Type == tea.KeyRunes && utf8.RuneCountInString(*value)+len(key.Runes) <= limit {
    988 		*value += string(key.Runes)
    989 	}
    990 }
    991 func clamp(value, length int) int {
    992 	if length <= 0 || value < 0 {
    993 		return 0
    994 	}
    995 	if value >= length {
    996 		return length - 1
    997 	}
    998 	return value
    999 }
   1000 func selectedPost(s *screen) *textlog.Post {
   1001 	if len(s.posts) == 0 {
   1002 		return nil
   1003 	}
   1004 	s.selected = clamp(s.selected, len(s.posts))
   1005 	return &s.posts[s.selected]
   1006 }
   1007 func prependUnique(posts []textlog.Post, p textlog.Post, limit int) []textlog.Post {
   1008 	out := []textlog.Post{p}
   1009 	for _, old := range posts {
   1010 		if old.ID != p.ID {
   1011 			out = append(out, old)
   1012 		}
   1013 	}
   1014 	if len(out) > limit {
   1015 		out = out[:limit]
   1016 	}
   1017 	return out
   1018 }
   1019 
   1020 func (m *Model) setThread(s *screen, result Result) {
   1021 	if result.Post == nil {
   1022 		s.posts, s.thread = nil, nil
   1023 		return
   1024 	}
   1025 	root := result.Post
   1026 	if result.Root != nil {
   1027 		root = result.Root
   1028 	}
   1029 	replies := append([]textlog.Reply(nil), result.Replies...)
   1030 	if result.Post.ID != root.ID {
   1031 		found := false
   1032 		for _, reply := range replies {
   1033 			if reply.ID == result.Post.ID {
   1034 				found = true
   1035 				break
   1036 			}
   1037 		}
   1038 		if !found {
   1039 			replies = append(replies, textlog.Reply{Post: *result.Post})
   1040 		}
   1041 	}
   1042 	s.thread = domain.BuildReplyThread(*root, replies)
   1043 	s.folded = make(map[int]bool)
   1044 	m.rebuildThread(s, result.Post.ID)
   1045 }
   1046 
   1047 func (m *Model) toggleThreadFold(s *screen) {
   1048 	post := selectedPost(s)
   1049 	if post == nil || !threadHasChildren(s.thread, post.ID) {
   1050 		return
   1051 	}
   1052 	s.folded[post.ID] = !s.folded[post.ID]
   1053 	m.rebuildThread(s, post.ID)
   1054 }
   1055 
   1056 func (m *Model) rebuildThread(s *screen, selectedID int) {
   1057 	byID := make(map[int]textlog.Post, len(s.thread))
   1058 	for _, item := range s.thread {
   1059 		byID[item.Post.ID] = item.Post
   1060 	}
   1061 	s.posts, s.depths, s.more = nil, nil, nil
   1062 	for _, item := range s.thread {
   1063 		if threadItemHidden(item.Post, byID, s.folded) {
   1064 			continue
   1065 		}
   1066 		s.posts = append(s.posts, item.Post)
   1067 		s.depths = append(s.depths, item.Depth)
   1068 		more := item.MoreCount
   1069 		if s.folded[item.Post.ID] {
   1070 			more = threadDescendantCount(s.thread, item.Post.ID)
   1071 		}
   1072 		s.more = append(s.more, more)
   1073 		if item.Post.ID == selectedID {
   1074 			s.selected = len(s.posts) - 1
   1075 		}
   1076 	}
   1077 	s.target = -1
   1078 }
   1079 
   1080 func threadItemHidden(post textlog.Post, byID map[int]textlog.Post, folded map[int]bool) bool {
   1081 	seen := make(map[int]bool)
   1082 	for post.ParentID != nil {
   1083 		parentID := *post.ParentID
   1084 		if folded[parentID] {
   1085 			return true
   1086 		}
   1087 		if seen[parentID] {
   1088 			return false
   1089 		}
   1090 		seen[parentID] = true
   1091 		parent, ok := byID[parentID]
   1092 		if !ok {
   1093 			return false
   1094 		}
   1095 		post = parent
   1096 	}
   1097 	return false
   1098 }
   1099 
   1100 func threadHasChildren(thread []domain.ThreadItem, id int) bool {
   1101 	for _, item := range thread {
   1102 		if item.Post.ParentID != nil && *item.Post.ParentID == id {
   1103 			return true
   1104 		}
   1105 	}
   1106 	return false
   1107 }
   1108 
   1109 func threadDescendantCount(thread []domain.ThreadItem, id int) int {
   1110 	byParent := make(map[int][]int)
   1111 	for _, item := range thread {
   1112 		if item.Post.ParentID != nil {
   1113 			byParent[*item.Post.ParentID] = append(byParent[*item.Post.ParentID], item.Post.ID)
   1114 		}
   1115 	}
   1116 	count := 0
   1117 	seen := map[int]bool{id: true}
   1118 	var visit func(int)
   1119 	visit = func(parent int) {
   1120 		for _, child := range byParent[parent] {
   1121 			if seen[child] {
   1122 				continue
   1123 			}
   1124 			seen[child] = true
   1125 			count++
   1126 			visit(child)
   1127 		}
   1128 	}
   1129 	visit(id)
   1130 	return count
   1131 }
   1132 func (m *Model) profileTabCount(s *screen) int {
   1133 	if m.me != nil && strings.EqualFold(m.me.Handle, s.handle) {
   1134 		return 6
   1135 	}
   1136 	return 5
   1137 }
   1138 func (m *Model) setProfileTab(s *screen) {
   1139 	s.selected, s.target = 0, -1
   1140 	switch s.activeTab {
   1141 	case 0:
   1142 		s.posts = s.notes
   1143 	case 1:
   1144 		s.posts = make([]textlog.Post, len(s.replies))
   1145 		for i := range s.replies {
   1146 			s.posts[i] = s.replies[i].Post
   1147 		}
   1148 	case 2:
   1149 		s.users = s.following
   1150 	case 3:
   1151 		s.tags = s.followingTags
   1152 	case 4:
   1153 		s.users = s.followers
   1154 	case 5:
   1155 		s.users = s.blocks
   1156 	}
   1157 }
   1158 func (m Model) profileReferences(s *screen) []textlog.UserReference {
   1159 	if s.kind == screenTag {
   1160 		return s.users
   1161 	}
   1162 	switch s.activeTab {
   1163 	case 2:
   1164 		return s.following
   1165 	case 4:
   1166 		return s.followers
   1167 	case 5:
   1168 		return s.blocks
   1169 	}
   1170 	return nil
   1171 }
   1172 func (m *Model) referenceKey(s *screen, key tea.KeyMsg) (tea.Cmd, bool) {
   1173 	if s.kind == screenProfile && s.activeTab == 3 {
   1174 		items := s.followingTags
   1175 		switch key.String() {
   1176 		case "j", "down":
   1177 			s.selected = clamp(s.selected+1, len(items))
   1178 			return nil, true
   1179 		case "k", "up":
   1180 			s.selected = clamp(s.selected-1, len(items))
   1181 			return nil, true
   1182 		case "g":
   1183 			s.selected = 0
   1184 			return nil, true
   1185 		case "G":
   1186 			s.selected = max(0, len(items)-1)
   1187 			return nil, true
   1188 		case "enter", "l", "right":
   1189 			if len(items) > 0 {
   1190 				return m.openTag(items[s.selected].Tag), true
   1191 			}
   1192 		case "o":
   1193 			if len(items) > 0 {
   1194 				return m.openURL(items[s.selected].URL), true
   1195 			}
   1196 		}
   1197 		return nil, false
   1198 	}
   1199 	items := m.profileReferences(s)
   1200 	switch key.String() {
   1201 	case "j", "down":
   1202 		s.selected = clamp(s.selected+1, len(items))
   1203 		return nil, true
   1204 	case "k", "up":
   1205 		s.selected = clamp(s.selected-1, len(items))
   1206 		return nil, true
   1207 	case "g":
   1208 		s.selected = 0
   1209 		return nil, true
   1210 	case "G":
   1211 		s.selected = max(0, len(items)-1)
   1212 		return nil, true
   1213 	case "enter", "l", "right":
   1214 		if len(items) > 0 {
   1215 			return m.openProfile(items[s.selected].Handle), true
   1216 		}
   1217 	case "o":
   1218 		if len(items) > 0 {
   1219 			return m.openURL(items[s.selected].URL), true
   1220 		}
   1221 	}
   1222 	return nil, false
   1223 }
   1224 
   1225 type target struct{ kind, value string }
   1226 
   1227 func targetsFor(p *textlog.Post) []target {
   1228 	if p == nil {
   1229 		return nil
   1230 	}
   1231 	out := []target{{"author", p.Author.Handle}, {"thread", fmt.Sprint(p.ID)}, {"reply", fmt.Sprint(p.ID)}}
   1232 	if domain.IsASCIIArt(p.Body, p.Tags) {
   1233 		return out
   1234 	}
   1235 	for _, token := range domain.TokenizeRichText(p.Body) {
   1236 		if token.Kind == domain.TokenLink {
   1237 			out = append(out, target{"link", token.URL})
   1238 		}
   1239 		if token.Kind == domain.TokenReference {
   1240 			if strings.HasPrefix(token.Text, "#") {
   1241 				out = append(out, target{"tag", strings.TrimPrefix(token.Text, "#")})
   1242 			} else {
   1243 				out = append(out, target{"mention", strings.TrimPrefix(token.Text, "@")})
   1244 			}
   1245 		}
   1246 	}
   1247 	return out
   1248 }
   1249 func (m *Model) activate(p textlog.Post, index int) tea.Cmd {
   1250 	targets := targetsFor(&p)
   1251 	if index < 0 || index >= len(targets) {
   1252 		return m.openPost(p.ID)
   1253 	}
   1254 	t := targets[index]
   1255 	switch t.kind {
   1256 	case "author":
   1257 		return m.openProfile(p.Author.Handle)
   1258 	case "thread":
   1259 		return m.openPost(p.ID)
   1260 	case "reply":
   1261 		if m.me == nil {
   1262 			return m.openLogin()
   1263 		}
   1264 		m.openCompose(&p, nil)
   1265 	case "link":
   1266 		return m.openURL(t.value)
   1267 	case "tag":
   1268 		return m.openTag(t.value)
   1269 	case "mention":
   1270 		return m.openProfile(t.value)
   1271 	}
   1272 	return nil
   1273 }
   1274 func errorText(err error) string {
   1275 	var api *textlog.APIError
   1276 	if errors.As(err, &api) && api.RetryAfter > 0 {
   1277 		return fmt.Sprintf("%s (retry in %ds)", api.Message, api.RetryAfter)
   1278 	}
   1279 	return err.Error()
   1280 }