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 }