commit 0d72a4ceb3a67beecde238aeaa05df782dd9d496
parent ec965c776c93720048a0cca1b9620e8b314a5324
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Tue, 25 Aug 2026 21:38:28 -0700
Implement Charmbracelet textlog TUI
Diffstat:
13 files changed, 2350 insertions(+), 84 deletions(-)
diff --git a/cmd/textlog/main.go b/cmd/textlog/main.go
@@ -0,0 +1,125 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "runtime"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/x/term"
+ "github.com/ryan/gotextlog/internal/config"
+ "github.com/ryan/gotextlog/internal/textlog"
+ "github.com/ryan/gotextlog/internal/tui"
+)
+
+const version = "1.0.0"
+const help = `textlog 1.0.0
+
+Usage: textlog [options]
+
+ --url <origin> Textlog-compatible instance
+ --theme <auto|light|dark> Color theme
+ --help Show help
+ --version Show version
+
+Environment: TEXTLOG_URL, TEXTLOG_TOKEN, TEXTLOG_THEME, NO_COLOR`
+
+type arguments struct {
+ url string
+ theme config.Theme
+ help, version bool
+}
+
+func parseArgs(values []string) (arguments, error) {
+ var result arguments
+ for index := 0; index < len(values); index++ {
+ switch values[index] {
+ case "--help", "-h":
+ result.help = true
+ case "--version", "-v":
+ result.version = true
+ case "--url":
+ index++
+ if index >= len(values) {
+ return result, errors.New("--url requires a value")
+ }
+ normalized, err := config.NormalizeBaseURL(values[index])
+ if err != nil {
+ return result, err
+ }
+ result.url = normalized
+ case "--theme":
+ index++
+ if index >= len(values) {
+ return result, errors.New("--theme requires a value")
+ }
+ result.theme = config.Theme(values[index])
+ if result.theme != config.ThemeAuto && result.theme != config.ThemeLight && result.theme != config.ThemeDark {
+ return result, errors.New("--theme must be auto, light, or dark")
+ }
+ default:
+ return result, fmt.Errorf("Unknown option: %s", values[index])
+ }
+ }
+ return result, nil
+}
+func main() { os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) }
+func run(values []string, stdin *os.File, stdout, stderr io.Writer) int {
+ args, err := parseArgs(values)
+ if err != nil {
+ fmt.Fprintln(stderr, err)
+ return 2
+ }
+ if args.help {
+ fmt.Fprintln(stdout, help)
+ return 0
+ }
+ if args.version {
+ fmt.Fprintln(stdout, version)
+ return 0
+ }
+ outFile, ok := stdout.(*os.File)
+ if !ok || !term.IsTerminal(stdin.Fd()) || !term.IsTerminal(outFile.Fd()) {
+ fmt.Fprintln(stderr, "textlog requires an interactive terminal")
+ return 1
+ }
+ env := config.Environment()
+ cfg, err := config.Load(env)
+ if err != nil {
+ fmt.Fprintln(stderr, err)
+ return 1
+ }
+ if args.url != "" {
+ cfg.BaseURL = args.url
+ }
+ if args.theme != "" {
+ cfg.Theme = args.theme
+ }
+ client := textlog.NewClient(cfg.BaseURL, cfg.Token, http.DefaultClient)
+ model := tui.New(tui.NewClientBackend(client), cfg, tui.Options{
+ SaveConfig: func(next config.Config) error { return config.Save(next, env) },
+ OpenURL: openBrowser,
+ })
+ program := tea.NewProgram(model, tea.WithAltScreen(), tea.WithInput(stdin), tea.WithOutput(outFile))
+ if _, err := program.Run(); err != nil {
+ fmt.Fprintln(stderr, err)
+ return 1
+ }
+ return 0
+}
+func openBrowser(url string) error {
+ var command *exec.Cmd
+ switch runtime.GOOS {
+ case "darwin":
+ command = exec.Command("open", url)
+ case "windows":
+ command = exec.Command("cmd", "/c", "start", "", url)
+ default:
+ command = exec.Command("xdg-open", url)
+ }
+ return command.Start()
+}
diff --git a/cmd/textlog/main_test.go b/cmd/textlog/main_test.go
@@ -0,0 +1,16 @@
+package main
+
+import "testing"
+
+func TestParseArgs(t *testing.T) {
+ got, err := parseArgs([]string{"--url", "https://x.test/", "--theme", "light"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.url != "https://x.test" || got.theme != "light" {
+ t.Fatalf("args=%#v", got)
+ }
+ if _, err := parseArgs([]string{"--wat"}); err == nil {
+ t.Fatal("accepted unknown option")
+ }
+}
diff --git a/doc/plan/charmbracelet-tui.md b/doc/plan/charmbracelet-tui.md
@@ -1,78 +0,0 @@
-# Charmbracelet TUI implementation plan
-
-## Architecture
-
-The Go module is `github.com/ryan/gotextlog`. The executable is `cmd/textlog`.
-
-`internal/textlog` owns API v1.1 wire types and HTTP/SSE behavior. It exports a
-`Client` with methods for public/authenticated feeds, search, posts and replies,
-profiles and relationship collections, tags, authentication, account mutation,
-post mutation, moderation, and a reconnectable-consumer-friendly `Firehose`.
-Methods take `context.Context`; collection methods take `limit int` and
-`cursor string`. The TUI defines its own narrow service interface from these
-methods so model tests can use a fake.
-
-`internal/config` owns URL normalization, environment precedence, platform
-config paths, and atomic owner-only persistence. It exposes `Load(env)`,
-`Save(config, env)`, and `NormalizeBaseURL`.
-
-`internal/domain` owns note validation, relative timestamps, terminal-safe
-text, rich-text tokenization, ASCII-art detection, and reply-thread ordering.
-It consumes `internal/textlog` wire types and has no terminal framework state.
-
-`internal/tui` owns the Bubble Tea model, commands, navigation stack, async
-message handling, list/editor/menu state, rendering, key bindings, theme, and
-browser launch. API and persistence calls are commands. The root model remains
-the owner of navigation, current-user/session state, and global status.
-
-## W1: API client
-
-Files: `internal/textlog/types.go`, `internal/textlog/client.go`, and their
-`*_test.go` files.
-
-Deliver all JSON endpoints represented by the reference implementation, bearer
-headers, structured errors with Retry-After, URL encoding/query pagination, and
-SSE parsing that tolerates chunk boundaries and malformed events. The coarse
-test drives an `httptest.Server` through representative GET, mutation, error,
-and split firehose requests.
-
-Verification: `go test ./internal/textlog` and `go test -race ./internal/textlog`.
-
-Dependency: none.
-
-## W2: configuration and domain behavior
-
-Files: `internal/config/config.go`, `internal/config/config_test.go`,
-`internal/domain/domain.go`, and `internal/domain/domain_test.go`.
-
-Deliver environment-aware configuration with atomic mode-0600 persistence and
-the complete pure helper behavior from the reference, including Unicode note
-limits, link/reference tokenization, terminal whitespace safety, and cycle-safe
-reply ordering. Domain types import `internal/textlog`.
-
-Verification: `go test ./internal/config ./internal/domain`.
-
-Dependency: W1 types (both workstreams may proceed concurrently because their
-files are disjoint).
-
-## W3: Bubble Tea application and executable
-
-Files: `internal/tui/*.go`, `cmd/textlog/main.go`, root acceptance tests,
-`go.mod`, and `go.sum`.
-
-Start with a coarse Bubble Tea model test covering initial feed load, keyboard
-navigation into a note, reply composition and preview, successful publication,
-and back navigation. Implement responsive feed/search/thread/profile/tag/live,
-compose/login/account/settings/help screens; guest and authenticated tabs;
-cursor pagination; rich note rendering and target navigation; mutations and
-moderation; SSE pause/retry; CLI flags and TTY checks. Use Bubble Tea, Bubbles,
-and Lip Gloss from Charmbracelet.
-
-Verification: `go test ./...`, `go test -race ./...`, and `go build ./cmd/textlog`.
-
-Dependency: W1 and W2. Integration is done in this workstream.
-
-## Documentation and completion
-
-Add `README.md` with build, controls, configuration, and API coverage. Remove
-this plan after all tests and build pass; retain durable behavior in the README.
diff --git a/doc/usage.md b/doc/usage.md
@@ -0,0 +1,75 @@
+# textlog terminal client
+
+`textlog` is a keyboard-first Textlog client built with Bubble Tea, Bubbles,
+and Lip Gloss.
+
+## Build and run
+
+Go 1.24.2 or newer is required.
+
+```sh
+go build -o textlog ./cmd/textlog
+./textlog
+```
+
+The process requires an interactive input and output terminal.
+
+## Controls
+
+Use the arrow keys or `j`/`k` to move, and Enter, right arrow, or `l` to
+open. `g` and `G` select the first and last item. `h`, left arrow, Escape,
+or `q` goes back; at the root it quits. `n` and `p` change cursor pages,
+and `R` reloads the current feed or search.
+
+Guest tabs are `1` hot, `2` latest, and `3` live. Signed-in tabs are `1`
+for you, `2` to me, `3` hot, `4` latest, and `5` live. Other global
+shortcuts are `0` profile, `/` search, `w` write, `a` account, and `?`
+help.
+
+In note lists, Tab and Shift+Tab cycle through the author, thread, reply,
+URLs, hashtags, and mentions; Enter opens the focused target. `r` replies,
+`u` opens the author, `t` opens the first tag, and `o` opens the note in a
+browser. Thread actions include `f`/`F` follow/unfollow, `b`/`B`
+block/unblock, `e` edit, `d` delete, and `!` report. Profile and tag tabs
+use Tab or left/right. Space pauses and resumes the live feed.
+
+Multiline editors use Enter for a newline and Ctrl+S to preview or submit.
+Settings can be selected with `j`/`k` and Enter; `t`, `u`, and `o` are
+shortcuts for theme, instance URL, and web app.
+
+## Configuration
+
+The default instance is `https://textlog.cc`. Command-line options are:
+
+- `--url <origin>`
+- `--theme auto|light|dark`
+- `--help`
+- `--version`
+
+Environment overrides are `TEXTLOG_URL`, `TEXTLOG_TOKEN`,
+`TEXTLOG_THEME`, and `NO_COLOR`. Settings and the session token are stored
+in the platform user configuration directory: `$XDG_CONFIG_HOME/textlog` on
+Linux, Application Support on macOS, and AppData on Windows. The configuration
+file is written atomically with owner-only permissions where supported.
+
+Sign-in uses Textlog's email-code flow. The client persists the token, restores
+the last authenticated feed, supports bio editing and sign-out, and opens URLs
+with the platform browser command.
+
+## API coverage
+
+The client implements Textlog API v1.1 JSON endpoints for public and
+personalized feeds, search, posts and reply trees, profiles and relationship
+lists, tags and followers, authentication and account updates, post creation,
+editing and deletion, follow/block actions, reporting, and the SSE firehose.
+Live connections reconnect after an interruption and malformed SSE events do
+not terminate an otherwise healthy stream.
+
+## Validation
+
+```sh
+go test ./...
+go test -race ./...
+go vet ./...
+go build ./cmd/textlog
+```
diff --git a/go.mod b/go.mod
@@ -0,0 +1,32 @@
+module github.com/ryan/gotextlog
+
+go 1.24.2
+
+require (
+ github.com/charmbracelet/bubbles v1.0.0
+ github.com/charmbracelet/bubbletea v1.3.10
+ github.com/charmbracelet/lipgloss v1.1.0
+ github.com/charmbracelet/x/term v0.2.2
+)
+
+require (
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
+ github.com/charmbracelet/x/ansi v0.11.6 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
+ github.com/clipperhouse/displaywidth v0.9.0 // indirect
+ github.com/clipperhouse/stringish v0.1.1 // indirect
+ github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
+ github.com/mattn/go-runewidth v0.0.19 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ golang.org/x/text v0.3.8 // indirect
+)
diff --git a/go.sum b/go.sum
@@ -0,0 +1,50 @@
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
+github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
+github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
+github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
+github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
+github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
+github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
+github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
diff --git a/internal/config/config.go b/internal/config/config.go
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"runtime"
+ "strconv"
"strings"
)
@@ -30,10 +31,12 @@ const (
)
type Config struct {
- BaseURL string `json:"baseUrl"`
- Theme Theme `json:"theme"`
- Token string `json:"token,omitempty"`
- LastTab FeedKind `json:"lastTab,omitempty"`
+ BaseURL string `json:"baseUrl"`
+ Theme Theme `json:"theme"`
+ Token string `json:"token,omitempty"`
+ LastTab FeedKind `json:"lastTab,omitempty"`
+ NoColor bool `json:"-"`
+ AutoLight bool `json:"-"`
}
func NormalizeBaseURL(value string) (string, error) {
@@ -117,7 +120,8 @@ func Load(env map[string]string) (Config, error) {
if env["TEXTLOG_THEME"] != "" {
theme = Theme(env["TEXTLOG_THEME"])
}
- if _, ok := env["NO_COLOR"]; ok {
+ _, noColor := env["NO_COLOR"]
+ if noColor {
theme = ThemeLight
}
if !validTheme(theme) {
@@ -131,7 +135,8 @@ func Load(env map[string]string) (Config, error) {
if !validTab(last) {
last = ""
}
- return Config{BaseURL: base, Theme: theme, Token: token, LastTab: last}, nil
+ background, _ := strconv.Atoi(env["COLORFGBG"][strings.LastIndex(env["COLORFGBG"], ";")+1:])
+ return Config{BaseURL: base, Theme: theme, Token: token, LastTab: last, NoColor: noColor, AutoLight: background > 8}, nil
}
func Save(cfg Config, env map[string]string) error {
path := ConfigPath(env)
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
@@ -102,6 +102,9 @@ func TestLoadDefaultsAndSanitizesStoredSelections(t *testing.T) {
if got.Theme != ThemeLight {
t.Fatalf("NO_COLOR theme = %q, want %q", got.Theme, ThemeLight)
}
+ if !got.NoColor {
+ t.Fatal("NO_COLOR was not retained for rendering")
+ }
missing, err := Load(isolatedEnv(t.TempDir()))
if err != nil {
@@ -195,3 +198,13 @@ func isolatedEnv(home string) map[string]string {
}
return env
}
+func TestLoadDetectsLightAutomaticTheme(t *testing.T) {
+ home := t.TempDir()
+ cfg, err := Load(map[string]string{"HOME": home, "XDG_CONFIG_HOME": home, "COLORFGBG": "0;15"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Theme != ThemeAuto || !cfg.AutoLight {
+ t.Fatalf("automatic theme detection = %#v", cfg)
+ }
+}
diff --git a/internal/tui/backend.go b/internal/tui/backend.go
@@ -0,0 +1,284 @@
+package tui
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/ryan/gotextlog/internal/config"
+ "github.com/ryan/gotextlog/internal/textlog"
+)
+
+type OperationKind string
+
+const (
+ OpFeed OperationKind = "feed"
+ OpSearch OperationKind = "search"
+ OpThread OperationKind = "thread"
+ OpProfile OperationKind = "profile"
+ OpTag OperationKind = "tag"
+ OpMe OperationKind = "me"
+ OpRequestCode OperationKind = "request-code"
+ OpVerifyCode OperationKind = "verify-code"
+ OpRevoke OperationKind = "revoke"
+ OpUpdateBio OperationKind = "update-bio"
+ OpCreatePost OperationKind = "create-post"
+ OpEditPost OperationKind = "edit-post"
+ OpDeletePost OperationKind = "delete-post"
+ OpFollow OperationKind = "follow"
+ OpBlock OperationKind = "block"
+ OpReport OperationKind = "report"
+)
+
+type Operation struct {
+ Kind OperationKind
+ Feed string
+ ID int
+ ParentID *int
+ Handle, Tag, Query, Cursor, Text, Email, Code string
+ Limit, Page int
+ Enabled bool
+ Reason textlog.ReportReason
+}
+
+type ActivityEntry struct {
+ Post textlog.Post
+ Context string
+ Compact bool
+}
+
+type Result struct {
+ Posts []textlog.Post
+ Entries []ActivityEntry
+ Replies []textlog.Reply
+ Post *textlog.Post
+ Parent *textlog.Post
+ Profile *textlog.Profile
+ Users []textlog.UserReference
+ Tags []textlog.TagReference
+ Followers []textlog.UserReference
+ Following []textlog.UserReference
+ FollowingTags []textlog.TagReference
+ Blocks []textlog.UserReference
+ Me *textlog.CurrentUser
+ Session *textlog.Session
+ NextCursor string
+}
+
+type Backend interface {
+ Execute(context.Context, Operation) (Result, error)
+ Firehose(context.Context) (<-chan textlog.Post, <-chan error)
+}
+
+type clientBackend struct{ client *textlog.Client }
+
+func NewClientBackend(client *textlog.Client) Backend { return &clientBackend{client: client} }
+func (b *clientBackend) Configure(cfg config.Config) {
+ b.client.BaseURL = cfg.BaseURL
+ b.client.Token = cfg.Token
+}
+func (b *clientBackend) Firehose(ctx context.Context) (<-chan textlog.Post, <-chan error) {
+ return b.client.Firehose(ctx)
+}
+
+func cursorValue(cursor *string) string {
+ if cursor == nil {
+ return ""
+ }
+ return *cursor
+}
+
+func (b *clientBackend) Execute(ctx context.Context, op Operation) (Result, error) {
+ limit := op.Limit
+ if limit == 0 {
+ limit = 20
+ }
+ switch op.Kind {
+ case OpFeed:
+ switch op.Feed {
+ case "latest":
+ value, err := b.client.Latest(ctx, limit, op.Cursor)
+ return Result{Posts: value.Data, NextCursor: cursorValue(value.Pagination.NextCursor)}, err
+ case "hot":
+ value, err := b.client.Hot(ctx, limit, op.Cursor)
+ return Result{Posts: value.Data, NextCursor: cursorValue(value.Pagination.NextCursor)}, err
+ case "for-you", "to-me":
+ var value textlog.ActivityCollection
+ var err error
+ if op.Feed == "for-you" {
+ value, err = b.client.ForYou(ctx, limit, op.Cursor)
+ } else {
+ value, err = b.client.ToMe(ctx, limit, op.Cursor)
+ }
+ if err != nil {
+ return Result{}, err
+ }
+ return Result{Entries: activityEntries(value.Data, op.Page), NextCursor: cursorValue(value.Pagination.NextCursor)}, nil
+ default:
+ return Result{}, fmt.Errorf("unknown feed %q", op.Feed)
+ }
+ case OpSearch:
+ value, err := b.client.Search(ctx, op.Query, limit, op.Cursor)
+ return Result{Posts: value.Data, NextCursor: cursorValue(value.Pagination.NextCursor)}, err
+ case OpThread:
+ postEnvelope, err := b.client.Post(ctx, op.ID)
+ if err != nil {
+ return Result{}, err
+ }
+ replies, err := b.client.Replies(ctx, op.ID, 5, 100, "")
+ if err != nil {
+ return Result{}, err
+ }
+ result := Result{Post: &postEnvelope.Data, Replies: replies.Data}
+ if postEnvelope.Data.ParentID != nil {
+ parent, parentErr := b.client.Post(ctx, *postEnvelope.Data.ParentID)
+ if parentErr == nil {
+ result.Parent = &parent.Data
+ }
+ }
+ return result, nil
+ case OpProfile:
+ profile, err := b.client.Profile(ctx, op.Handle)
+ if err != nil {
+ return Result{}, err
+ }
+ notes, err := b.client.UserNotes(ctx, op.Handle, 100, "")
+ if err != nil {
+ return Result{}, err
+ }
+ replies, err := b.client.UserReplies(ctx, op.Handle, 100, "")
+ if err != nil {
+ return Result{}, err
+ }
+ following, err := b.client.UserFollowing(ctx, op.Handle, 100, "")
+ if err != nil {
+ return Result{}, err
+ }
+ tags, err := b.client.UserFollowingTags(ctx, op.Handle, 100, "")
+ if err != nil {
+ return Result{}, err
+ }
+ followers, err := b.client.UserFollowers(ctx, op.Handle, 100, "")
+ if err != nil {
+ return Result{}, err
+ }
+ result := Result{Profile: &profile.Data, Posts: notes.Data, Replies: postsAsReplies(replies.Data), Following: following.Data,
+ FollowingTags: tags.Data, Followers: followers.Data}
+ if op.Enabled {
+ blocks, blockErr := b.client.UserBlocks(ctx, op.Handle, 100, "")
+ if blockErr == nil {
+ result.Blocks = blocks.Data
+ }
+ }
+ return result, nil
+ case OpTag:
+ details, err := b.client.Tag(ctx, op.Tag)
+ if err != nil {
+ return Result{}, err
+ }
+ posts, err := b.client.TagPosts(ctx, op.Tag, 100, "")
+ if err != nil {
+ return Result{}, err
+ }
+ followers, err := b.client.TagFollowers(ctx, op.Tag, 100, "")
+ if err != nil {
+ return Result{}, err
+ }
+ return Result{Tags: []textlog.TagReference{details.Data}, Posts: posts.Data, Followers: followers.Data}, nil
+ case OpMe:
+ value, err := b.client.Me(ctx)
+ return Result{Me: &value.Data}, err
+ case OpRequestCode:
+ _, err := b.client.RequestCode(ctx, op.Email)
+ return Result{}, err
+ case OpVerifyCode:
+ value, err := b.client.VerifyCode(ctx, op.Email, op.Code)
+ if err == nil {
+ b.client.Token = value.Data.Token
+ }
+ return Result{Session: &value.Data, Me: &value.Data.User}, err
+ case OpRevoke:
+ _, err := b.client.Revoke(ctx)
+ if err == nil {
+ b.client.Token = ""
+ }
+ return Result{}, err
+ case OpUpdateBio:
+ value, err := b.client.UpdateBio(ctx, op.Text)
+ return Result{Me: &value.Data}, err
+ case OpCreatePost:
+ value, err := b.client.CreatePost(ctx, op.Text, op.ParentID)
+ return Result{Post: &value.Data}, err
+ case OpEditPost:
+ value, err := b.client.EditPost(ctx, op.ID, op.Text)
+ return Result{Post: &value.Data}, err
+ case OpDeletePost:
+ _, err := b.client.DeletePost(ctx, op.ID)
+ return Result{}, err
+ case OpFollow:
+ _, err := b.client.Follow(ctx, op.Handle, op.Enabled)
+ return Result{}, err
+ case OpBlock:
+ _, err := b.client.Block(ctx, op.Handle, op.Enabled)
+ return Result{}, err
+ case OpReport:
+ _, err := b.client.Report(ctx, op.ID, op.Reason)
+ return Result{}, err
+ default:
+ return Result{}, fmt.Errorf("unknown operation %q", op.Kind)
+ }
+}
+
+func postsAsReplies(posts []textlog.Post) []textlog.Reply {
+ out := make([]textlog.Reply, len(posts))
+ for i, post := range posts {
+ out[i] = textlog.Reply{Post: post}
+ }
+ return out
+}
+
+func activityEntries(activities []textlog.Activity, page int) []ActivityEntry {
+ entries := make([]ActivityEntry, 0, len(activities))
+ for index, activity := range activities {
+ var post textlog.Post
+ if json.Unmarshal(activity.Payload, &post) == nil && post.ID != 0 {
+ contextText := ""
+ if activity.Type == "reply" {
+ contextText = "replied to you"
+ }
+ if activity.Type == "mention" {
+ contextText = "mentioned you"
+ }
+ entries = append(entries, ActivityEntry{Post: post, Context: contextText})
+ continue
+ }
+ var payload struct {
+ Actor textlog.UserReference `json:"actor"`
+ Target struct {
+ Handle string `json:"handle"`
+ Tag string `json:"tag"`
+ } `json:"target"`
+ }
+ if json.Unmarshal(activity.Payload, &payload) != nil || payload.Actor.Handle == "" {
+ continue
+ }
+ contextText := ""
+ switch activity.Type {
+ case "user_follow":
+ contextText = "followed you"
+ if payload.Target.Handle != "" {
+ contextText = "followed @" + payload.Target.Handle
+ }
+ case "tag_follow":
+ contextText = "followed #" + payload.Target.Tag
+ case "signup":
+ contextText = "signed up"
+ }
+ if contextText == "" {
+ continue
+ }
+ entries = append(entries, ActivityEntry{Post: textlog.Post{ID: -(page*1000 + index + 1), CreatedAt: activity.CreatedAt,
+ Author: payload.Actor}, Context: contextText, Compact: true})
+ }
+ return entries
+}
diff --git a/internal/tui/browser.go b/internal/tui/browser.go
@@ -0,0 +1,23 @@
+package tui
+
+import (
+ "fmt"
+ "os/exec"
+ "runtime"
+)
+
+func openBrowser(url string) error {
+ var command *exec.Cmd
+ switch runtime.GOOS {
+ case "darwin":
+ command = exec.Command("open", url)
+ case "windows":
+ command = exec.Command("cmd", "/c", "start", "", url)
+ default:
+ command = exec.Command("xdg-open", url)
+ }
+ if err := command.Start(); err != nil {
+ return fmt.Errorf("open browser: %w", err)
+ }
+ return nil
+}
diff --git a/internal/tui/model.go b/internal/tui/model.go
@@ -0,0 +1,1140 @@
+package tui
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/charmbracelet/bubbles/spinner"
+ tea "github.com/charmbracelet/bubbletea"
+
+ "github.com/ryan/gotextlog/internal/config"
+ "github.com/ryan/gotextlog/internal/domain"
+ "github.com/ryan/gotextlog/internal/textlog"
+)
+
+type screenKind string
+
+const (
+ screenFeed screenKind = "feed"
+ screenSearch screenKind = "search"
+ screenPost screenKind = "post"
+ screenProfile screenKind = "profile"
+ screenTag screenKind = "tag"
+ screenLive screenKind = "live"
+ screenCompose screenKind = "compose"
+ screenLogin screenKind = "login"
+ screenAccount screenKind = "account"
+ screenSettings screenKind = "settings"
+ screenHelp screenKind = "help"
+)
+
+type screen struct {
+ serial int
+ kind screenKind
+ feed string
+ id int
+ handle, tag, query, input, phase string
+ loading bool
+ err string
+ posts, notes []textlog.Post
+ entries []ActivityEntry
+ depths, more []int
+ selected, target, page int
+ cursors []string
+ nextCursor string
+ profile *textlog.Profile
+ users, following, followers, blocks []textlog.UserReference
+ tags, followingTags []textlog.TagReference
+ replies []textlog.Reply
+ activeTab int
+ parent, edit *textlog.Post
+ preview bool
+ menu []string
+ menuSelected int
+ paused bool
+ streamPosts <-chan textlog.Post
+ streamErrors <-chan error
+ streamCancel context.CancelFunc
+ streamState string
+}
+
+type Options struct {
+ SaveConfig func(config.Config) error
+ OpenURL func(string) error
+ Context context.Context
+}
+
+type Model struct {
+ backend Backend
+ config config.Config
+ options Options
+ stack []*screen
+ me *textlog.CurrentUser
+ status string
+ statusError bool
+ width, height, nextSerial int
+ spinner spinner.Model
+}
+
+type loadedMsg struct {
+ serial int
+ op Operation
+ result Result
+ err error
+}
+type savedMsg struct {
+ cfg config.Config
+ status string
+ err error
+}
+type openedMsg struct{ err error }
+type streamStartedMsg struct {
+ serial int
+ posts <-chan textlog.Post
+ errs <-chan error
+ cancel context.CancelFunc
+}
+type liveMsg struct {
+ serial int
+ post *textlog.Post
+ err error
+ closed bool
+}
+type reconnectMsg struct{ serial int }
+
+func New(backend Backend, cfg config.Config, options Options) Model {
+ if options.Context == nil {
+ options.Context = context.Background()
+ }
+ if options.SaveConfig == nil {
+ options.SaveConfig = func(c config.Config) error { return config.Save(c, nil) }
+ }
+ if options.OpenURL == nil {
+ options.OpenURL = openBrowser
+ }
+ m := Model{backend: backend, config: cfg, options: options, width: 80, height: 24, nextSerial: 1,
+ spinner: spinner.New(spinner.WithSpinner(spinner.MiniDot))}
+ s := m.newScreen(screenFeed)
+ s.feed = string(config.FeedHot)
+ if cfg.Token != "" && cfg.LastTab != "" {
+ s.feed = string(cfg.LastTab)
+ }
+ s.loading, s.cursors = true, []string{""}
+ m.stack = []*screen{s}
+ return m
+}
+
+func (m *Model) newScreen(kind screenKind) *screen {
+ s := &screen{serial: m.nextSerial, kind: kind, target: -1, streamState: "connecting"}
+ m.nextSerial++
+ return s
+}
+func (m Model) current() *screen { return m.stack[len(m.stack)-1] }
+func (m *Model) find(serial int) *screen {
+ for _, s := range m.stack {
+ if s.serial == serial {
+ return s
+ }
+ }
+ return nil
+}
+
+func (m Model) Init() tea.Cmd {
+ cmds := []tea.Cmd{m.command(m.current(), Operation{Kind: OpFeed, Feed: m.current().feed}), m.spinner.Tick}
+ if m.config.Token != "" {
+ cmds = append(cmds, m.command(nil, Operation{Kind: OpMe}))
+ }
+ return tea.Batch(cmds...)
+}
+func (m Model) command(s *screen, op Operation) tea.Cmd {
+ serial := 0
+ if s != nil {
+ serial = s.serial
+ }
+ return func() tea.Msg {
+ result, err := m.backend.Execute(m.options.Context, op)
+ return loadedMsg{serial, op, result, err}
+ }
+}
+
+func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := message.(type) {
+ case tea.WindowSizeMsg:
+ m.width, m.height = msg.Width, msg.Height
+ case spinner.TickMsg:
+ var cmd tea.Cmd
+ m.spinner, cmd = m.spinner.Update(msg)
+ return m, cmd
+ case loadedMsg:
+ return m.applyLoaded(msg)
+ case savedMsg:
+ if msg.err != nil {
+ m.setStatus(msg.err.Error(), true)
+ } else {
+ m.config = msg.cfg
+ if backend, ok := m.backend.(interface{ Configure(config.Config) }); ok {
+ backend.Configure(msg.cfg)
+ }
+ if msg.status != "" {
+ m.setStatus(msg.status, false)
+ }
+ }
+ case openedMsg:
+ if msg.err != nil {
+ m.setStatus(msg.err.Error(), true)
+ }
+ case streamStartedMsg:
+ s := m.find(msg.serial)
+ if s == nil {
+ msg.cancel()
+ break
+ }
+ s.streamPosts, s.streamErrors, s.streamCancel, s.streamState = msg.posts, msg.errs, msg.cancel, "live"
+ return m, waitLive(s)
+ case liveMsg:
+ s := m.find(msg.serial)
+ if s == nil {
+ break
+ }
+ if s.paused {
+ s.streamState = "paused"
+ return m, nil
+ }
+ if msg.post != nil {
+ s.posts = prependUnique(s.posts, *msg.post, 100)
+ return m, waitLive(s)
+ }
+ s.streamState = "disconnected · retrying"
+ if !s.paused {
+ return m, tea.Tick(time.Second, func(time.Time) tea.Msg { return reconnectMsg{s.serial} })
+ }
+ case reconnectMsg:
+ if s := m.find(msg.serial); s != nil && !s.paused {
+ return m, m.startStream(s)
+ }
+ case tea.KeyMsg:
+ return m.key(msg)
+ }
+ return m, nil
+}
+
+func (m Model) applyLoaded(msg loadedMsg) (tea.Model, tea.Cmd) {
+ if msg.serial == 0 && msg.op.Kind == OpMe {
+ if msg.err == nil {
+ m.me = msg.result.Me
+ } else {
+ m.setStatus(errorText(msg.err), true)
+ }
+ return m, nil
+ }
+ s := m.find(msg.serial)
+ if s == nil {
+ return m, nil
+ }
+ s.loading = false
+ if msg.err != nil {
+ s.err = errorText(msg.err)
+ return m, nil
+ }
+ s.err = ""
+ switch msg.op.Kind {
+ case OpFeed, OpSearch:
+ s.posts, s.entries, s.nextCursor = msg.result.Posts, msg.result.Entries, msg.result.NextCursor
+ if len(s.entries) > 0 {
+ s.posts = make([]textlog.Post, len(s.entries))
+ for i := range s.entries {
+ s.posts[i] = s.entries[i].Post
+ }
+ }
+ s.selected, s.target = 0, -1
+ case OpThread:
+ s.posts, s.depths, s.more = makeThread(msg.result)
+ case OpProfile:
+ s.profile, s.notes, s.posts, s.replies = msg.result.Profile, msg.result.Posts, msg.result.Posts, msg.result.Replies
+ s.following, s.followingTags, s.followers, s.blocks = msg.result.Following, msg.result.FollowingTags, msg.result.Followers, msg.result.Blocks
+ s.users, s.tags = s.following, s.followingTags
+ case OpTag:
+ s.posts, s.users = msg.result.Posts, msg.result.Followers
+ if len(msg.result.Tags) > 0 {
+ s.tags = []textlog.TagReference{msg.result.Tags[0]}
+ }
+ case OpRequestCode:
+ s.phase, s.query, s.input = "code", msg.op.Email, ""
+ m.setStatus("Check your email for the six-digit code", false)
+ case OpVerifyCode:
+ if msg.result.Session != nil {
+ m.me, m.config.Token = msg.result.Me, msg.result.Session.Token
+ m.pop()
+ return m, m.saveCmd(m.config, "Signed in as @"+msg.result.Session.User.Handle)
+ }
+ case OpRevoke:
+ m.me, m.config.Token = nil, ""
+ m.pop()
+ return m, m.saveCmd(m.config, "Signed out")
+ case OpUpdateBio:
+ m.me, s.phase = msg.result.Me, ""
+ m.setStatus("Bio updated", false)
+ case OpCreatePost:
+ if msg.result.Post != nil {
+ m.setStatus("Note posted", false)
+ m.pop()
+ next := m.newScreen(screenPost)
+ next.id, next.loading = msg.result.Post.ID, true
+ m.stack = append(m.stack, next)
+ return m, m.command(next, Operation{Kind: OpThread, ID: next.id})
+ }
+ case OpEditPost:
+ m.setStatus("Note updated", false)
+ m.pop()
+ case OpDeletePost:
+ m.setStatus("Note deleted", false)
+ m.pop()
+ case OpFollow:
+ verb := "Following @"
+ if !msg.op.Enabled {
+ verb = "Unfollowed @"
+ }
+ m.setStatus(verb+msg.op.Handle, false)
+ case OpBlock:
+ verb := "Blocked @"
+ if !msg.op.Enabled {
+ verb = "Unblocked @"
+ }
+ m.setStatus(verb+msg.op.Handle, false)
+ case OpReport:
+ m.setStatus("Report submitted", false)
+ s.menu = nil
+ }
+ return m, nil
+}
+
+func (m Model) key(key tea.KeyMsg) (tea.Model, tea.Cmd) {
+ s := m.current()
+ if len(s.menu) > 0 {
+ return m.menuKey(key)
+ }
+ if s.kind == screenCompose {
+ return m.composeKey(key)
+ }
+ if s.kind == screenLogin {
+ return m.loginKey(key)
+ }
+ if s.kind == screenSearch && s.phase == "input" {
+ return m.searchKey(key)
+ }
+ if s.kind == screenAccount && s.phase == "bio" {
+ return m.bioKey(key)
+ }
+ if s.kind == screenSettings && s.phase == "url" {
+ return m.urlKey(key)
+ }
+ if s.loading {
+ if key.String() == "q" || key.Type == tea.KeyEsc {
+ m.pop()
+ }
+ return m, nil
+ }
+ if cmd, ok := m.screenKey(s, key); ok {
+ return m, cmd
+ }
+ return m.globalKey(key)
+}
+
+func (m *Model) screenKey(s *screen, key tea.KeyMsg) (tea.Cmd, bool) {
+ k := key.String()
+ switch s.kind {
+ case screenFeed, screenSearch:
+ if cmd, ok := m.listKey(s, key); ok {
+ return cmd, true
+ }
+ if k == "R" {
+ s.loading = true
+ return m.reloadList(s), true
+ }
+ if k == "n" && s.nextCursor != "" {
+ s.page++
+ s.cursors = append(s.cursors, s.nextCursor)
+ s.loading = true
+ return m.reloadList(s), true
+ }
+ if k == "p" && s.page > 0 {
+ s.page--
+ s.loading = true
+ return m.reloadList(s), true
+ }
+ case screenPost:
+ if cmd, ok := m.listKey(s, key); ok {
+ return cmd, true
+ }
+ p := selectedPost(s)
+ if p == nil {
+ return nil, false
+ }
+ switch k {
+ case "T":
+ if p.TopID != nil {
+ return m.openPost(*p.TopID), true
+ }
+ case "P":
+ if p.ParentID != nil {
+ return m.openPost(*p.ParentID), true
+ }
+ case "e":
+ if m.owns(*p) {
+ m.openCompose(nil, p)
+ return nil, true
+ }
+ case "d":
+ if m.owns(*p) {
+ s.menu = []string{"delete note", "cancel"}
+ return nil, true
+ }
+ case "!":
+ if !m.owns(*p) {
+ s.menu = []string{"report harassment", "report spam", "report impersonation", "report other", "cancel"}
+ return nil, true
+ }
+ case "f", "F":
+ return m.action(s, Operation{Kind: OpFollow, Handle: p.Author.Handle, Enabled: k == "f"}), true
+ case "b", "B":
+ return m.action(s, Operation{Kind: OpBlock, Handle: p.Author.Handle, Enabled: k == "b"}), true
+ }
+ case screenProfile:
+ if k == "tab" || k == "right" {
+ s.activeTab = (s.activeTab + 1) % m.profileTabCount(s)
+ m.setProfileTab(s)
+ return nil, true
+ }
+ if k == "shift+tab" || k == "left" {
+ s.activeTab = (s.activeTab + m.profileTabCount(s) - 1) % m.profileTabCount(s)
+ m.setProfileTab(s)
+ return nil, true
+ }
+ if s.activeTab < 2 {
+ if cmd, ok := m.listKey(s, key); ok {
+ return cmd, true
+ }
+ } else if cmd, ok := m.referenceKey(s, key); ok {
+ return cmd, true
+ }
+ if k == "o" && s.profile != nil {
+ return m.openURL(s.profile.URL), true
+ }
+ if k == "f" || k == "F" {
+ return m.action(s, Operation{Kind: OpFollow, Handle: s.handle, Enabled: k == "f"}), true
+ }
+ if k == "b" || k == "B" {
+ return m.action(s, Operation{Kind: OpBlock, Handle: s.handle, Enabled: k == "b"}), true
+ }
+ case screenTag:
+ if k == "tab" || k == "shift+tab" || k == "left" || k == "right" {
+ s.activeTab = 1 - s.activeTab
+ s.selected = 0
+ return nil, true
+ }
+ if s.activeTab == 0 {
+ if cmd, ok := m.listKey(s, key); ok {
+ return cmd, true
+ }
+ } else if cmd, ok := m.referenceKey(s, key); ok {
+ return cmd, true
+ }
+ case screenLive:
+ if k == " " {
+ s.paused = !s.paused
+ if s.paused {
+ if s.streamCancel != nil {
+ s.streamCancel()
+ }
+ s.streamState = "paused"
+ return nil, true
+ }
+ s.streamState = "connecting"
+ return m.startStream(s), true
+ }
+ if cmd, ok := m.listKey(s, key); ok {
+ return cmd, true
+ }
+ case screenAccount:
+ if m.me == nil {
+ return nil, false
+ }
+ switch k {
+ case "e":
+ s.phase, s.input = "bio", m.me.Bio
+ return nil, true
+ case "s":
+ s.loading = true
+ return m.command(s, Operation{Kind: OpRevoke}), true
+ case "u":
+ return m.openProfile(m.me.Handle), true
+ case ",":
+ m.stack = append(m.stack, m.newScreen(screenSettings))
+ return nil, true
+ }
+ case screenSettings:
+ switch k {
+ case "j", "down":
+ s.menuSelected = min(3, s.menuSelected+1)
+ return nil, true
+ case "k", "up":
+ s.menuSelected = max(0, s.menuSelected-1)
+ return nil, true
+ case "t":
+ s.menuSelected = 0
+ case "u":
+ s.menuSelected = 1
+ case "o":
+ s.menuSelected = 2
+ case "enter":
+ default:
+ return nil, false
+ }
+ switch s.menuSelected {
+ case 0:
+ m.cycleTheme()
+ return m.saveCmd(m.config, "Theme updated"), true
+ case 1:
+ s.phase, s.input = "url", m.config.BaseURL
+ return nil, true
+ case 2:
+ return m.openURL(m.config.BaseURL), true
+ default:
+ m.pop()
+ return nil, true
+ }
+ }
+ return nil, false
+}
+
+func (m *Model) listKey(s *screen, key tea.KeyMsg) (tea.Cmd, bool) {
+ switch key.String() {
+ case "j", "down":
+ s.selected, s.target = clamp(s.selected+1, len(s.posts)), -1
+ return nil, true
+ case "k", "up":
+ s.selected, s.target = clamp(s.selected-1, len(s.posts)), -1
+ return nil, true
+ case "g":
+ s.selected, s.target = 0, -1
+ return nil, true
+ case "G":
+ s.selected, s.target = max(0, len(s.posts)-1), -1
+ return nil, true
+ case "tab":
+ targets := targetsFor(selectedPost(s))
+ if len(targets) > 0 {
+ s.target = (s.target + 1) % len(targets)
+ }
+ return nil, true
+ case "shift+tab":
+ targets := targetsFor(selectedPost(s))
+ if len(targets) > 0 {
+ s.target--
+ if s.target < 0 {
+ s.target = len(targets) - 1
+ }
+ }
+ return nil, true
+ case "enter", "l", "right":
+ p := selectedPost(s)
+ if p == nil {
+ return nil, true
+ }
+ if s.target >= 0 {
+ return m.activate(*p, s.target), true
+ }
+ if p.ID < 0 {
+ return m.openProfile(p.Author.Handle), true
+ }
+ return m.openPost(p.ID), true
+ case "r":
+ p := selectedPost(s)
+ if p != nil {
+ if m.me == nil {
+ return m.openLogin(), true
+ }
+ m.openCompose(p, nil)
+ }
+ return nil, true
+ case "u":
+ p := selectedPost(s)
+ if p != nil {
+ return m.openProfile(p.Author.Handle), true
+ }
+ return nil, true
+ case "t":
+ p := selectedPost(s)
+ if p != nil && len(p.Tags) > 0 {
+ return m.openTag(p.Tags[0]), true
+ }
+ return nil, true
+ case "o":
+ p := selectedPost(s)
+ if p != nil {
+ return m.openURL(p.URL), true
+ }
+ return nil, true
+ }
+ return nil, false
+}
+
+func (m *Model) globalKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
+ k := key.String()
+ if k == "q" || k == "esc" || k == "h" || k == "left" {
+ if len(m.stack) > 1 {
+ m.pop()
+ return *m, nil
+ }
+ return *m, tea.Quit
+ }
+ switch k {
+ case "?":
+ m.stack = append(m.stack, m.newScreen(screenHelp))
+ case "/":
+ s := m.newScreen(screenSearch)
+ s.phase = "input"
+ m.stack = append(m.stack, s)
+ case "w":
+ if m.me == nil {
+ return *m, m.openLogin()
+ }
+ m.openCompose(nil, nil)
+ case "a":
+ if m.me == nil {
+ return *m, m.openLogin()
+ }
+ m.stack = append(m.stack, m.newScreen(screenAccount))
+ case "0":
+ if m.me != nil {
+ return *m, m.openProfile(m.me.Handle)
+ }
+ case "1":
+ if m.config.Token != "" {
+ return *m, m.openFeed("for-you")
+ }
+ return *m, m.openFeed("hot")
+ case "2":
+ if m.config.Token != "" {
+ return *m, m.openFeed("to-me")
+ }
+ return *m, m.openFeed("latest")
+ case "3":
+ if m.config.Token != "" {
+ return *m, m.openFeed("hot")
+ }
+ return *m, m.openLive()
+ case "4":
+ if m.config.Token != "" {
+ return *m, m.openFeed("latest")
+ }
+ case "5":
+ if m.config.Token != "" {
+ return *m, m.openLive()
+ }
+ }
+ return *m, nil
+}
+
+func (m *Model) composeKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
+ s := m.current()
+ k := key.String()
+ if s.preview {
+ switch k {
+ case "j", "down":
+ s.menuSelected = min(2, s.menuSelected+1)
+ case "k", "up":
+ s.menuSelected = max(0, s.menuSelected-1)
+ case "esc":
+ s.preview = false
+ case "enter":
+ if s.menuSelected == 0 {
+ op := Operation{Kind: OpCreatePost, Text: s.input}
+ if s.parent != nil {
+ op.ParentID = &s.parent.ID
+ }
+ if s.edit != nil {
+ op.Kind, op.ID = OpEditPost, s.edit.ID
+ }
+ s.loading = true
+ return *m, m.command(s, op)
+ } else if s.menuSelected == 1 {
+ s.preview = false
+ } else {
+ m.pop()
+ }
+ }
+ return *m, nil
+ }
+ if k == "esc" {
+ m.pop()
+ return *m, nil
+ }
+ if k == "ctrl+s" {
+ if problem := domain.ValidatePost(s.input); problem != "" {
+ m.setStatus(problem, true)
+ } else {
+ s.preview = true
+ }
+ return *m, nil
+ }
+ if key.Type == tea.KeyEnter {
+ if strings.Count(s.input, "\n") < 9 {
+ s.input += "\n"
+ }
+ return *m, nil
+ }
+ edit(&s.input, key, 280)
+ return *m, nil
+}
+func (m *Model) loginKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
+ s := m.current()
+ if key.String() == "esc" {
+ if s.phase == "code" {
+ s.phase, s.input = "email", ""
+ } else {
+ m.pop()
+ }
+ return *m, nil
+ }
+ if key.Type == tea.KeyEnter {
+ if s.phase == "email" {
+ if !strings.Contains(s.input, "@") || !strings.Contains(s.input, ".") {
+ m.setStatus("Enter a valid email address", true)
+ return *m, nil
+ }
+ s.loading = true
+ return *m, m.command(s, Operation{Kind: OpRequestCode, Email: s.input})
+ }
+ validCode := len(s.input) == 6
+ for _, digit := range s.input {
+ validCode = validCode && digit >= '0' && digit <= '9'
+ }
+ if !validCode {
+ m.setStatus("Enter the six-digit code", true)
+ return *m, nil
+ }
+ s.loading = true
+ return *m, m.command(s, Operation{Kind: OpVerifyCode, Email: s.query, Code: s.input})
+ }
+ edit(&s.input, key, 320)
+ return *m, nil
+}
+func (m *Model) searchKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
+ s := m.current()
+ if key.String() == "esc" {
+ m.pop()
+ return *m, nil
+ }
+ if key.Type == tea.KeyEnter && strings.TrimSpace(s.input) != "" {
+ s.query, s.phase, s.loading = strings.TrimSpace(s.input), "results", true
+ s.cursors = []string{""}
+ return *m, m.command(s, Operation{Kind: OpSearch, Query: s.query})
+ }
+ edit(&s.input, key, 200)
+ return *m, nil
+}
+func (m *Model) bioKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
+ s := m.current()
+ if key.String() == "esc" {
+ s.phase = ""
+ return *m, nil
+ }
+ if key.String() == "ctrl+s" {
+ s.loading = true
+ return *m, m.command(s, Operation{Kind: OpUpdateBio, Text: s.input})
+ }
+ if key.Type == tea.KeyEnter {
+ s.input += "\n"
+ } else {
+ edit(&s.input, key, 1000)
+ }
+ return *m, nil
+}
+func (m *Model) urlKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
+ s := m.current()
+ if key.String() == "esc" {
+ s.phase = ""
+ return *m, nil
+ }
+ if key.Type == tea.KeyEnter {
+ value, err := config.NormalizeBaseURL(s.input)
+ if err != nil {
+ m.setStatus(err.Error(), true)
+ return *m, nil
+ }
+ m.config.BaseURL = value
+ s.phase = ""
+ return *m, m.saveCmd(m.config, "Instance updated")
+ }
+ edit(&s.input, key, 500)
+ return *m, nil
+}
+
+func (m *Model) menuKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
+ s := m.current()
+ switch key.String() {
+ case "j", "down":
+ s.menuSelected = (s.menuSelected + 1) % len(s.menu)
+ case "k", "up":
+ s.menuSelected = (s.menuSelected + len(s.menu) - 1) % len(s.menu)
+ case "q", "esc":
+ s.menu = nil
+ case "enter":
+ p := selectedPost(s)
+ if s.menu[s.menuSelected] == "cancel" {
+ s.menu = nil
+ return *m, nil
+ }
+ s.loading = true
+ if s.menu[s.menuSelected] == "delete note" {
+ s.menu = nil
+ return *m, m.command(s, Operation{Kind: OpDeletePost, ID: p.ID})
+ }
+ reasons := []textlog.ReportReason{textlog.ReportHarassment, textlog.ReportSpam, textlog.ReportImpersonation, textlog.ReportOther}
+ return *m, m.command(s, Operation{Kind: OpReport, ID: p.ID, Reason: reasons[s.menuSelected]})
+ }
+ return *m, nil
+}
+
+func (m *Model) reloadList(s *screen) tea.Cmd {
+ cursor := ""
+ if s.page < len(s.cursors) {
+ cursor = s.cursors[s.page]
+ }
+ if s.kind == screenSearch {
+ return m.command(s, Operation{Kind: OpSearch, Query: s.query, Cursor: cursor})
+ }
+ return m.command(s, Operation{Kind: OpFeed, Feed: s.feed, Cursor: cursor, Page: s.page})
+}
+func (m *Model) openFeed(feed string) tea.Cmd {
+ s := m.newScreen(screenFeed)
+ s.feed, s.loading, s.cursors = feed, true, []string{""}
+ m.stack[len(m.stack)-1] = s
+ load := m.command(s, Operation{Kind: OpFeed, Feed: feed})
+ if m.config.Token != "" && m.config.LastTab != config.FeedKind(feed) {
+ m.config.LastTab = config.FeedKind(feed)
+ return tea.Batch(load, m.saveCmd(m.config, ""))
+ }
+ return load
+}
+func (m *Model) openPost(id int) tea.Cmd {
+ s := m.newScreen(screenPost)
+ s.id, s.loading = id, true
+ m.stack = append(m.stack, s)
+ return m.command(s, Operation{Kind: OpThread, ID: id})
+}
+func (m *Model) openProfile(handle string) tea.Cmd {
+ s := m.newScreen(screenProfile)
+ s.handle, s.loading = handle, true
+ m.stack = append(m.stack, s)
+ return m.command(s, Operation{Kind: OpProfile, Handle: handle, Enabled: m.me != nil && strings.EqualFold(m.me.Handle, handle)})
+}
+func (m *Model) openTag(tag string) tea.Cmd {
+ s := m.newScreen(screenTag)
+ s.tag, s.loading = strings.TrimPrefix(tag, "#"), true
+ m.stack = append(m.stack, s)
+ return m.command(s, Operation{Kind: OpTag, Tag: s.tag})
+}
+func (m *Model) openLogin() tea.Cmd {
+ s := m.newScreen(screenLogin)
+ s.phase = "email"
+ m.stack = append(m.stack, s)
+ return nil
+}
+func (m *Model) openLive() tea.Cmd {
+ s := m.newScreen(screenLive)
+ m.stack = append(m.stack, s)
+ return m.startStream(s)
+}
+func (m *Model) openCompose(parent, edit *textlog.Post) {
+ s := m.newScreen(screenCompose)
+ s.parent, s.edit = parent, edit
+ if edit != nil {
+ s.input = edit.Body
+ }
+ m.stack = append(m.stack, s)
+}
+func (m *Model) pop() {
+ if len(m.stack) > 1 {
+ s := m.current()
+ if s.streamCancel != nil {
+ s.streamCancel()
+ }
+ m.stack = m.stack[:len(m.stack)-1]
+ }
+}
+
+func (m *Model) action(s *screen, op Operation) tea.Cmd {
+ if m.me == nil {
+ return m.openLogin()
+ }
+ s.loading = true
+ return m.command(s, op)
+}
+func (m Model) saveCmd(cfg config.Config, status string) tea.Cmd {
+ return func() tea.Msg { return savedMsg{cfg: cfg, status: status, err: m.options.SaveConfig(cfg)} }
+}
+func (m Model) openURL(url string) tea.Cmd {
+ return func() tea.Msg {
+ if url == "" {
+ return openedMsg{errors.New("no browser URL available")}
+ }
+ return openedMsg{m.options.OpenURL(url)}
+ }
+}
+func (m Model) startStream(s *screen) tea.Cmd {
+ return func() tea.Msg {
+ ctx, cancel := context.WithCancel(m.options.Context)
+ posts, errs := m.backend.Firehose(ctx)
+ return streamStartedMsg{s.serial, posts, errs, cancel}
+ }
+}
+func waitLive(s *screen) tea.Cmd {
+ return func() tea.Msg {
+ select {
+ case p, ok := <-s.streamPosts:
+ if !ok {
+ return liveMsg{serial: s.serial, closed: true}
+ }
+ return liveMsg{serial: s.serial, post: &p}
+ case err, ok := <-s.streamErrors:
+ if !ok {
+ return liveMsg{serial: s.serial, closed: true}
+ }
+ return liveMsg{serial: s.serial, err: err}
+ }
+ }
+}
+
+func (m *Model) setStatus(text string, isError bool) { m.status, m.statusError = text, isError }
+func (m Model) owns(p textlog.Post) bool {
+ return m.me != nil && strings.EqualFold(m.me.Handle, p.Author.Handle)
+}
+func (m *Model) cycleTheme() {
+ themes := []config.Theme{config.ThemeAuto, config.ThemeLight, config.ThemeDark}
+ for i, t := range themes {
+ if t == m.config.Theme {
+ m.config.Theme = themes[(i+1)%len(themes)]
+ return
+ }
+ }
+}
+func edit(value *string, key tea.KeyMsg, limit int) {
+ if key.Type == tea.KeyBackspace || key.Type == tea.KeyDelete {
+ if *value != "" {
+ _, n := utf8.DecodeLastRuneInString(*value)
+ *value = (*value)[:len(*value)-n]
+ }
+ return
+ }
+ if key.Type == tea.KeyRunes && utf8.RuneCountInString(*value)+len(key.Runes) <= limit {
+ *value += string(key.Runes)
+ }
+}
+func clamp(value, length int) int {
+ if length <= 0 || value < 0 {
+ return 0
+ }
+ if value >= length {
+ return length - 1
+ }
+ return value
+}
+func selectedPost(s *screen) *textlog.Post {
+ if len(s.posts) == 0 {
+ return nil
+ }
+ s.selected = clamp(s.selected, len(s.posts))
+ return &s.posts[s.selected]
+}
+func prependUnique(posts []textlog.Post, p textlog.Post, limit int) []textlog.Post {
+ out := []textlog.Post{p}
+ for _, old := range posts {
+ if old.ID != p.ID {
+ out = append(out, old)
+ }
+ }
+ if len(out) > limit {
+ out = out[:limit]
+ }
+ return out
+}
+
+func makeThread(result Result) ([]textlog.Post, []int, []int) {
+ if result.Post == nil {
+ return nil, nil, nil
+ }
+ items := domain.BuildReplyThread(*result.Post, result.Replies)
+ var posts []textlog.Post
+ var depths, more []int
+ if result.Post.TopID != nil {
+ posts = append(posts, textlog.Post{ID: *result.Post.TopID, Body: "↑ top"})
+ depths = append(depths, 0)
+ more = append(more, -1)
+ }
+ for i, item := range items {
+ posts = append(posts, item.Post)
+ depths = append(depths, item.Depth)
+ more = append(more, item.MoreCount)
+ if i == 0 && result.Parent != nil {
+ posts = append(posts, *result.Parent)
+ depths = append(depths, 1)
+ more = append(more, -2)
+ }
+ }
+ return posts, depths, more
+}
+func (m *Model) profileTabCount(s *screen) int {
+ if m.me != nil && strings.EqualFold(m.me.Handle, s.handle) {
+ return 6
+ }
+ return 5
+}
+func (m *Model) setProfileTab(s *screen) {
+ s.selected, s.target = 0, -1
+ switch s.activeTab {
+ case 0:
+ s.posts = s.notes
+ case 1:
+ s.posts = make([]textlog.Post, len(s.replies))
+ for i := range s.replies {
+ s.posts[i] = s.replies[i].Post
+ }
+ case 2:
+ s.users = s.following
+ case 3:
+ s.tags = s.followingTags
+ case 4:
+ s.users = s.followers
+ case 5:
+ s.users = s.blocks
+ }
+}
+func (m Model) profileReferences(s *screen) []textlog.UserReference {
+ if s.kind == screenTag {
+ return s.users
+ }
+ switch s.activeTab {
+ case 2:
+ return s.following
+ case 4:
+ return s.followers
+ case 5:
+ return s.blocks
+ }
+ return nil
+}
+func (m *Model) referenceKey(s *screen, key tea.KeyMsg) (tea.Cmd, bool) {
+ if s.kind == screenProfile && s.activeTab == 3 {
+ items := s.followingTags
+ switch key.String() {
+ case "j", "down":
+ s.selected = clamp(s.selected+1, len(items))
+ return nil, true
+ case "k", "up":
+ s.selected = clamp(s.selected-1, len(items))
+ return nil, true
+ case "g":
+ s.selected = 0
+ return nil, true
+ case "G":
+ s.selected = max(0, len(items)-1)
+ return nil, true
+ case "enter", "l", "right":
+ if len(items) > 0 {
+ return m.openTag(items[s.selected].Tag), true
+ }
+ case "o":
+ if len(items) > 0 {
+ return m.openURL(items[s.selected].URL), true
+ }
+ }
+ return nil, false
+ }
+ items := m.profileReferences(s)
+ switch key.String() {
+ case "j", "down":
+ s.selected = clamp(s.selected+1, len(items))
+ return nil, true
+ case "k", "up":
+ s.selected = clamp(s.selected-1, len(items))
+ return nil, true
+ case "g":
+ s.selected = 0
+ return nil, true
+ case "G":
+ s.selected = max(0, len(items)-1)
+ return nil, true
+ case "enter", "l", "right":
+ if len(items) > 0 {
+ return m.openProfile(items[s.selected].Handle), true
+ }
+ case "o":
+ if len(items) > 0 {
+ return m.openURL(items[s.selected].URL), true
+ }
+ }
+ return nil, false
+}
+
+type target struct{ kind, value string }
+
+func targetsFor(p *textlog.Post) []target {
+ if p == nil {
+ return nil
+ }
+ out := []target{{"author", p.Author.Handle}, {"thread", fmt.Sprint(p.ID)}, {"reply", fmt.Sprint(p.ID)}}
+ if domain.IsASCIIArt(p.Body, p.Tags) {
+ return out
+ }
+ for _, token := range domain.TokenizeRichText(p.Body) {
+ if token.Kind == domain.TokenLink {
+ out = append(out, target{"link", token.URL})
+ }
+ if token.Kind == domain.TokenReference {
+ if strings.HasPrefix(token.Text, "#") {
+ out = append(out, target{"tag", strings.TrimPrefix(token.Text, "#")})
+ } else {
+ out = append(out, target{"mention", strings.TrimPrefix(token.Text, "@")})
+ }
+ }
+ }
+ return out
+}
+func (m *Model) activate(p textlog.Post, index int) tea.Cmd {
+ targets := targetsFor(&p)
+ if index < 0 || index >= len(targets) {
+ return m.openPost(p.ID)
+ }
+ t := targets[index]
+ switch t.kind {
+ case "author":
+ return m.openProfile(p.Author.Handle)
+ case "thread":
+ return m.openPost(p.ID)
+ case "reply":
+ if m.me == nil {
+ return m.openLogin()
+ }
+ m.openCompose(&p, nil)
+ case "link":
+ return m.openURL(t.value)
+ case "tag":
+ return m.openTag(t.value)
+ case "mention":
+ return m.openProfile(t.value)
+ }
+ return nil
+}
+func errorText(err error) string {
+ var api *textlog.APIError
+ if errors.As(err, &api) && api.RetryAfter > 0 {
+ return fmt.Sprintf("%s (retry in %ds)", api.Message, api.RetryAfter)
+ }
+ return err.Error()
+}
diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go
@@ -0,0 +1,201 @@
+package tui
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/charmbracelet/bubbles/spinner"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/ryan/gotextlog/internal/config"
+ "github.com/ryan/gotextlog/internal/textlog"
+)
+
+type acceptanceBackend struct{ calls []Operation }
+
+func (b *acceptanceBackend) Execute(_ context.Context, op Operation) (Result, error) {
+ b.calls = append(b.calls, op)
+ root := textlog.Post{ID: 1, Body: "first note", CreatedAt: time.Now().Add(-time.Minute),
+ Author: textlog.UserReference{Handle: "alice"}, ReplyCount: 1}
+ switch op.Kind {
+ case OpFeed:
+ return Result{Posts: []textlog.Post{root}}, nil
+ case OpThread:
+ return Result{Post: &root, Replies: []textlog.Reply{{Post: textlog.Post{ID: 2, ParentID: intPtr(1),
+ Body: "a reply", CreatedAt: time.Now(), Author: textlog.UserReference{Handle: "bob"}}, Depth: 1}}}, nil
+ case OpCreatePost:
+ created := textlog.Post{ID: 9, ParentID: op.ParentID, Body: op.Text, CreatedAt: time.Now(),
+ Author: textlog.UserReference{Handle: "me"}}
+ return Result{Post: &created}, nil
+ case OpMe:
+ return Result{Me: &textlog.CurrentUser{Handle: "me", CanPost: true}}, nil
+ }
+ return Result{}, nil
+}
+
+func (b *acceptanceBackend) Firehose(context.Context) (<-chan textlog.Post, <-chan error) {
+ posts := make(chan textlog.Post)
+ errs := make(chan error)
+ close(posts)
+ close(errs)
+ return posts, errs
+}
+
+func intPtr(value int) *int { return &value }
+
+func settle(t *testing.T, model Model, cmd tea.Cmd) Model {
+ t.Helper()
+ if cmd == nil {
+ return model
+ }
+ msg := cmd()
+ if _, ok := msg.(spinner.TickMsg); ok {
+ return model
+ }
+ if batch, ok := msg.(tea.BatchMsg); ok {
+ for _, child := range batch {
+ model = settle(t, model, child)
+ }
+ return model
+ }
+ updated, next := model.Update(msg)
+ model = updated.(Model)
+ return settle(t, model, next)
+}
+
+func key(model Model, value string) (Model, tea.Cmd) {
+ var msg tea.KeyMsg
+ switch value {
+ case "enter":
+ msg = tea.KeyMsg{Type: tea.KeyEnter}
+ case "ctrl+s":
+ msg = tea.KeyMsg{Type: tea.KeyCtrlS}
+ default:
+ msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)}
+ }
+ updated, cmd := model.Update(msg)
+ return updated.(Model), cmd
+}
+
+func TestCompleteFeedThreadReplyFlow(t *testing.T) {
+ backend := &acceptanceBackend{}
+ model := New(backend, config.Config{BaseURL: "https://example.test", Theme: config.ThemeDark, Token: "token"}, Options{})
+ model = settle(t, model, model.Init())
+ model, cmd := key(model, "enter")
+ model = settle(t, model, cmd)
+ if model.current().kind != screenPost {
+ t.Fatalf("open feed item: %#v", model.current())
+ }
+ if !strings.Contains(model.View(), "a reply") {
+ t.Fatalf("thread did not render reply:\n%s", model.View())
+ }
+ model, _ = key(model, "r")
+ if model.current().kind != screenCompose {
+ t.Fatalf("reply screen: %#v", model.current())
+ }
+ model, _ = key(model, "reply from tui")
+ model, _ = key(model, "ctrl+s")
+ if !model.current().preview {
+ t.Fatal("Ctrl+S did not open preview")
+ }
+ model, cmd = key(model, "enter")
+ model = settle(t, model, cmd)
+ if model.current().kind != screenPost || model.current().id != 9 {
+ t.Fatalf("publish screen: %#v", model.current())
+ }
+ if model.status != "Note posted" {
+ t.Fatalf("status=%q", model.status)
+ }
+ model, _ = key(model, "q")
+ if model.current().kind == screenPost && model.current().id == 9 {
+ t.Fatal("q did not navigate back")
+ }
+ if len(backend.calls) < 4 {
+ t.Fatalf("calls=%v", backend.calls)
+ }
+}
+func TestFollowingTagNavigationAndSettingsMenu(t *testing.T) {
+ backend := &acceptanceBackend{}
+ model := New(backend, config.Config{BaseURL: "https://example.test", Theme: config.ThemeDark, Token: "token"}, Options{})
+ profile := model.newScreen(screenProfile)
+ profile.activeTab = 3
+ profile.followingTags = []textlog.TagReference{{Tag: "go"}}
+ model.stack = append(model.stack, profile)
+ model, cmd := key(model, "enter")
+ model = settle(t, model, cmd)
+ if model.current().kind != screenTag || model.current().tag != "go" {
+ t.Fatalf("following tag did not open: %#v", model.current())
+ }
+
+ settings := model.newScreen(screenSettings)
+ model.stack = append(model.stack, settings)
+ model, _ = key(model, "j")
+ model, _ = key(model, "enter")
+ if model.current().phase != "url" {
+ t.Fatalf("settings selection did not open URL editor: %#v", model.current())
+ }
+}
+
+func TestLoginCodeRequiresDigits(t *testing.T) {
+ model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeDark}, Options{})
+ login := model.newScreen(screenLogin)
+ login.phase, login.query, login.input = "code", "me@example.test", "abcdef"
+ model.stack = append(model.stack, login)
+ model, cmd := key(model, "enter")
+ if cmd != nil || model.status != "Enter the six-digit code" {
+ t.Fatalf("accepted non-numeric login code: status=%q cmd=%v", model.status, cmd != nil)
+ }
+}
+
+type configurableAcceptanceBackend struct {
+ acceptanceBackend
+ configured []config.Config
+}
+
+func (b *configurableAcceptanceBackend) Configure(cfg config.Config) {
+ b.configured = append(b.configured, cfg)
+}
+
+func TestPersistsFeedAndAppliesSavedConfiguration(t *testing.T) {
+ backend := &configurableAcceptanceBackend{}
+ var saved []config.Config
+ model := New(backend, config.Config{BaseURL: "https://example.test", Theme: config.ThemeDark, Token: "token"}, Options{
+ SaveConfig: func(cfg config.Config) error {
+ saved = append(saved, cfg)
+ return nil
+ },
+ })
+ model = settle(t, model, model.Init())
+ saved = nil
+ backend.configured = nil
+ model, cmd := key(model, "4")
+ model = settle(t, model, cmd)
+ if len(saved) != 1 || saved[0].LastTab != config.FeedLatest {
+ t.Fatalf("last feed was not persisted: %#v", saved)
+ }
+ if len(backend.configured) != 1 || backend.configured[0].LastTab != config.FeedLatest {
+ t.Fatalf("saved config was not applied to backend: %#v", backend.configured)
+ }
+}
+func TestFocusedRichTextTargetIsVisible(t *testing.T) {
+ model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeDark}, Options{})
+ model.current().loading = false
+ model.current().posts = []textlog.Post{{ID: 1, Body: "visit https://example.test", Author: textlog.UserReference{Handle: "alice"}}}
+ model, _ = key(model, "tab")
+ if model.current().target != 0 {
+ t.Fatalf("focused target was not selected: target=%d", model.current().target)
+ }
+}
+func TestPausedLiveScreenIgnoresStreamClosure(t *testing.T) {
+ model := New(&acceptanceBackend{}, config.Config{BaseURL: "https://example.test", Theme: config.ThemeDark}, Options{})
+ live := model.newScreen(screenLive)
+ live.paused = true
+ live.streamState = "paused"
+ model.stack = append(model.stack, live)
+ updated, cmd := model.Update(liveMsg{serial: live.serial, closed: true})
+ model = updated.(Model)
+ if cmd != nil || model.current().streamState != "paused" {
+ t.Fatalf("paused stream reconnected: state=%q cmd=%v", model.current().streamState, cmd != nil)
+ }
+}
diff --git a/internal/tui/view.go b/internal/tui/view.go
@@ -0,0 +1,380 @@
+package tui
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/ryan/gotextlog/internal/config"
+ "github.com/ryan/gotextlog/internal/domain"
+ "github.com/ryan/gotextlog/internal/textlog"
+)
+
+type palette struct{ ink, muted, soft, accent, danger, success, quote lipgloss.Color }
+
+func colors(theme config.Theme, noColor, autoLight bool) palette {
+ if noColor {
+ return palette{}
+ }
+ if theme == config.ThemeLight || (theme == config.ThemeAuto && autoLight) {
+ return palette{"#20231f", "#747c72", "#d9dbd4", "#55734a", "#7a3f39", "#466342", "#e9e9e3"}
+ }
+ return palette{"#e5e8e1", "#969d93", "#343a33", "#9abd8e", "#efb3aa", "#b9d5b2", "#222622"}
+}
+func (m Model) View() string {
+ if len(m.stack) == 0 {
+ return ""
+ }
+ p := colors(m.config.Theme, m.config.NoColor, m.config.AutoLight)
+ s := m.current()
+ header := m.header(s, p)
+ body := m.screenView(s, p)
+ if m.status != "" {
+ style := lipgloss.NewStyle().Foreground(p.success)
+ if m.statusError {
+ style = style.Foreground(p.danger)
+ }
+ body = style.Render(" "+m.status) + "\n" + body
+ }
+ footer := lipgloss.NewStyle().Foreground(p.muted).BorderTop(true).BorderForeground(p.soft).Width(max(1, m.width)).Render(m.footer(s))
+ available := max(1, m.height-lipgloss.Height(header)-lipgloss.Height(footer))
+ body = lipgloss.NewStyle().Height(available).MaxHeight(available).Width(max(1, m.width)).Render(body)
+ return header + "\n" + body + "\n" + footer
+}
+func (m *Model) header(s *screen, p palette) string {
+ active := m.title(s)
+ auth := m.config.Token != ""
+ tabs := []string{}
+ if auth {
+ tabs = []string{"1 for you", "2 to me", "3 hot", "4 latest", "5 live"}
+ } else {
+ tabs = []string{"1 hot", "2 latest", "3 live"}
+ }
+ for i, t := range tabs {
+ if strings.Contains(t, active) {
+ tabs[i] = lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(t)
+ } else {
+ tabs[i] = lipgloss.NewStyle().Foreground(p.muted).Render(t)
+ }
+ }
+ who := "guest"
+ if m.me != nil {
+ who = "0 @" + m.me.Handle
+ }
+ left := lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(">_ textlog") + " " + strings.Join(tabs, " ")
+ space := max(1, m.width-lipgloss.Width(left)-lipgloss.Width(who)-2)
+ return lipgloss.NewStyle().BorderBottom(true).BorderForeground(p.soft).Width(max(1, m.width)).Render(" " + left + strings.Repeat(" ", space) + lipgloss.NewStyle().Foreground(p.muted).Render(who))
+}
+func (m *Model) title(s *screen) string {
+ switch s.kind {
+ case screenFeed:
+ return string(s.feed)
+ case screenPost:
+ return fmt.Sprintf("note #%d", s.id)
+ case screenProfile:
+ return "@" + s.handle
+ case screenTag:
+ return "#" + s.tag
+ case screenSearch:
+ return "search"
+ case screenLive:
+ return "live"
+ case screenCompose:
+ return "compose"
+ case screenLogin:
+ return "login"
+ case screenAccount:
+ return "account"
+ case screenSettings:
+ return "settings"
+ default:
+ return "help"
+ }
+}
+func editingView(s *screen) bool {
+ return (s.kind == screenSearch && s.query == "") || s.kind == screenCompose || s.kind == screenLogin || (s.kind == screenAccount && s.phase == "bio") || (s.kind == screenSettings && s.phase == "url")
+}
+
+func (m *Model) footer(s *screen) string {
+ if editingView(s) {
+ return " Enter continue · Esc cancel"
+ }
+ if s.kind == screenPost {
+ return " Tab target · Enter open · r reply · u profile · f/F follow · b/B block · ! report · e edit · d delete"
+ }
+ return " ↑↓/jk move enter open / search w write ? help q back"
+}
+func (m *Model) screenView(s *screen, p palette) string {
+ if s.loading {
+ return lipgloss.NewStyle().Foreground(p.muted).Padding(1).Render("loading···")
+ }
+ if s.err != "" {
+ return lipgloss.NewStyle().Foreground(p.danger).Padding(1).Render(s.err)
+ }
+ switch s.kind {
+ case screenFeed:
+ return m.postsView(s, p)
+ case screenSearch:
+ if s.query == "" {
+ return editor("search public notes", s.input, false, p)
+ }
+ return lipgloss.NewStyle().Foreground(p.muted).Render(" results for “"+s.query+"”") + "\n" + m.postsView(s, p)
+ case screenPost:
+ return m.postsView(s, p)
+ case screenProfile:
+ return m.profileView(s, p)
+ case screenTag:
+ return m.tagView(s, p)
+ case screenLive:
+ return lipgloss.NewStyle().Foreground(p.success).Render(" ● "+s.streamState+" · Space pause/resume") + "\n" + m.postsView(s, p)
+ case screenCompose:
+ return m.composeView(s, p)
+ case screenLogin:
+ label := "email address"
+ if s.phase == "code" {
+ label = "code sent to " + s.query
+ }
+ return editor(label, s.input, s.phase == "code", p)
+ case screenAccount:
+ return m.accountView(s, p)
+ case screenSettings:
+ return m.settingsView(s, p)
+ case screenHelp:
+ return m.helpView(p)
+ }
+ return ""
+}
+
+func (m *Model) postsView(s *screen, p palette) string {
+ if len(s.posts) == 0 {
+ return lipgloss.NewStyle().Foreground(p.muted).Padding(1).Render("No notes here.")
+ }
+ rows := max(1, (m.height-6)/5)
+ start := s.selected - rows/2
+ if start < 0 {
+ start = 0
+ }
+ end := min(len(s.posts), start+rows)
+ if end-start < rows {
+ start = max(0, end-rows)
+ }
+ parts := make([]string, 0, end-start+1)
+ for i := start; i < end; i++ {
+ depth := 0
+ if i < len(s.depths) {
+ depth = s.depths[i]
+ }
+ focused := -1
+ if i == s.selected {
+ focused = s.target
+ }
+ note := renderNote(s.posts[i], i == s.selected, focused, max(20, m.width-depth*2), p)
+ if depth > 0 {
+ lines := strings.Split(note, "\n")
+ for j := range lines {
+ lines[j] = strings.Repeat("│ ", depth) + lines[j]
+ }
+ note = strings.Join(lines, "\n")
+ }
+ parts = append(parts, note)
+ if i < len(s.more) && s.more[i] > 0 {
+ parts = append(parts, lipgloss.NewStyle().Foreground(p.muted).Render(strings.Repeat(" ", depth+1)+fmt.Sprintf("more (%d replies)", s.more[i])))
+ }
+ }
+ if s.kind == screenFeed || s.kind == screenSearch {
+ parts = append(parts, lipgloss.NewStyle().Foreground(p.muted).Render(fmt.Sprintf(" page %d%s", s.page+1, map[bool]string{true: " · n next", false: ""}[s.nextCursor != ""])))
+ }
+ return strings.Join(parts, "\n")
+}
+func renderNote(post textlog.Post, selected bool, focused, width int, p palette) string {
+ replies := fmt.Sprintf("%d replies", post.ReplyCount)
+ if post.ReplyCount == 1 {
+ replies = "1 reply"
+ }
+ when := relative(post.CreatedAt)
+ authorStyle := lipgloss.NewStyle().Foreground(p.accent).Reverse(focused == 0)
+ threadStyle := lipgloss.NewStyle().Foreground(p.muted).Reverse(focused == 1)
+ replyStyle := lipgloss.NewStyle().Foreground(p.accent).Reverse(focused == 2)
+ meta := authorStyle.Render("@"+post.Author.Handle) + " " + threadStyle.Render(when+" · "+replies) + " " + replyStyle.Render("reply")
+ body := renderRich(post.Body, post.Tags, focused-3, p)
+ border := p.soft
+ if selected {
+ border = p.accent
+ }
+ return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(border).Padding(0, 1).Width(max(10, width-2)).Render(meta + "\n" + body)
+}
+func relative(value time.Time) string {
+ if value.IsZero() {
+ return "now"
+ }
+ seconds := int(time.Since(value).Seconds())
+ if seconds < 0 {
+ seconds = 0
+ }
+ if seconds < 60 {
+ return fmt.Sprintf("%ds", seconds)
+ }
+ if seconds < 3600 {
+ return fmt.Sprintf("%dm", seconds/60)
+ }
+ if seconds < 86400 {
+ return fmt.Sprintf("%dh", seconds/3600)
+ }
+ if seconds < 86400*30 {
+ return fmt.Sprintf("%dd", seconds/86400)
+ }
+ return value.Format("2006-01-02")
+}
+func renderRich(body string, tags []string, focused int, p palette) string {
+ body = domain.TerminalSafeText(body)
+ if domain.IsASCIIArt(body, tags) {
+ return body
+ }
+ var b strings.Builder
+ interactive := -1
+ for _, token := range domain.TokenizeRichText(body) {
+ style := lipgloss.NewStyle().Foreground(p.ink)
+ switch token.Kind {
+ case domain.TokenLink:
+ interactive++
+ style = style.Foreground(p.accent).Underline(true).Reverse(focused == interactive)
+ case domain.TokenReference:
+ interactive++
+ style = style.Foreground(p.accent).Bold(true).Reverse(focused == interactive)
+ case domain.TokenCode:
+ style = style.Foreground(p.muted)
+ }
+ b.WriteString(style.Render(token.Text))
+ }
+ return b.String()
+}
+func editor(label, value string, secret bool, p palette) string {
+ shown := value
+ if secret {
+ shown = strings.Repeat("•", domain.CodePointLength(value))
+ }
+ box := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(p.accent).Padding(0, 1).Render(shown + lipgloss.NewStyle().Reverse(true).Render(" "))
+ return lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(label) + "\n" + box + "\n" + lipgloss.NewStyle().Foreground(p.muted).Render("Enter continue · Esc cancel"))
+}
+
+func (m *Model) composeView(s *screen, p palette) string {
+ label := "What's on your mind?"
+ if s.parent != nil {
+ label = "reply to @" + s.parent.Author.Handle
+ }
+ if s.edit != nil {
+ label = "edit note"
+ }
+ if !s.preview {
+ return editor(label, s.input, false, p) + fmt.Sprintf("\n %d/280", domain.CodePointLength(s.input))
+ }
+ preview := textlog.Post{Body: s.input, CreatedAt: time.Now(), Author: textlog.UserReference{Handle: m.me.Handle}}
+ items := []string{"publish", "keep editing", "cancel"}
+ for i := range items {
+ prefix := " "
+ if i == s.menuSelected {
+ prefix = "› "
+ items[i] = lipgloss.NewStyle().Foreground(p.accent).Render(prefix + items[i])
+ } else {
+ items[i] = prefix + items[i]
+ }
+ }
+ return lipgloss.NewStyle().Padding(1).Bold(true).Render(label) + "\n" + renderNote(preview, true, -1, m.width, p) + "\n" + lipgloss.NewStyle().Bold(true).Render("Publish this note?") + "\n" + strings.Join(items, "\n")
+}
+func (m *Model) profileView(s *screen, p palette) string {
+ if s.profile == nil {
+ return "Profile not found"
+ }
+ profile := s.profile
+ tabs := []string{fmt.Sprintf("%d notes", profile.PostCount), fmt.Sprintf("%d replies", profile.RepliesCount), fmt.Sprintf("%d following users", profile.FollowingUserCount), fmt.Sprintf("%d following tags", profile.FollowingTagCount), fmt.Sprintf("%d followers", profile.FollowerCount)}
+ if m.me != nil && strings.EqualFold(m.me.Handle, s.handle) {
+ tabs = append(tabs, fmt.Sprintf("%d blocked", profile.BlockedUserCount))
+ }
+ for i := range tabs {
+ if i == s.activeTab {
+ tabs[i] = lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(tabs[i])
+ } else {
+ tabs[i] = lipgloss.NewStyle().Foreground(p.muted).Render(tabs[i])
+ }
+ }
+ header := lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("@"+profile.Handle) + "\n" + lipgloss.NewStyle().Foreground(p.muted).Render(profile.Bio) + "\n" + strings.Join(tabs, " "))
+ if s.activeTab < 2 {
+ return header + "\n" + m.postsView(s, p)
+ }
+ if s.activeTab == 3 {
+ return header + "\n" + referenceTags(s.tags, s.selected, p)
+ }
+ return header + "\n" + referenceUsers(s.users, s.selected, p)
+}
+func (m *Model) tagView(s *screen, p palette) string {
+ if len(s.tags) == 0 {
+ return "Tag not found"
+ }
+ details := &s.tags[0]
+ tabs := []string{fmt.Sprintf("%d notes", details.PostCount), fmt.Sprintf("%d followers", details.FollowerCount)}
+ tabs[s.activeTab] = lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render(tabs[s.activeTab])
+ header := lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("#"+details.Tag) + "\n" + strings.Join(tabs, " "))
+ if s.activeTab == 0 {
+ return header + "\n" + m.postsView(s, p)
+ }
+ return header + "\n" + referenceUsers(s.users, s.selected, p)
+}
+func referenceUsers(items []textlog.UserReference, selected int, p palette) string {
+ if len(items) == 0 {
+ return lipgloss.NewStyle().Foreground(p.muted).Render(" No users here.")
+ }
+ lines := make([]string, len(items))
+ for i, v := range items {
+ prefix := " "
+ if i == selected {
+ prefix = "› "
+ lines[i] = lipgloss.NewStyle().Foreground(p.accent).Render(prefix + "@" + v.Handle)
+ } else {
+ lines[i] = prefix + "@" + v.Handle
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+func referenceTags(items []textlog.TagReference, selected int, p palette) string {
+ if len(items) == 0 {
+ return lipgloss.NewStyle().Foreground(p.muted).Render(" No tags here.")
+ }
+ lines := make([]string, len(items))
+ for i, v := range items {
+ prefix := " "
+ if i == selected {
+ prefix = "› "
+ }
+ lines[i] = prefix + "#" + v.Tag
+ }
+ return strings.Join(lines, "\n")
+}
+func (m *Model) accountView(s *screen, p palette) string {
+ if s.phase == "bio" {
+ return editor("edit bio", s.input, false, p)
+ }
+ if m.me == nil {
+ return "You are not signed in."
+ }
+ return lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("@"+m.me.Handle) + "\n" + m.me.Email + "\n" + m.me.Bio + "\n" + lipgloss.NewStyle().Foreground(p.muted).Render(map[bool]string{true: "email verified", false: "email not verified"}[m.me.EmailVerified]) + "\n\n" + lipgloss.NewStyle().Foreground(p.accent).Render("e edit bio · u profile · , settings · s sign out"))
+}
+func (m *Model) settingsView(s *screen, p palette) string {
+ if s.phase == "url" {
+ return editor("Textlog instance URL", s.input, false, p)
+ }
+ items := []string{"theme: " + string(m.config.Theme), "change instance URL", "open web app", "back"}
+ for i := range items {
+ prefix := " "
+ if i == s.menuSelected {
+ prefix = "› "
+ items[i] = lipgloss.NewStyle().Foreground(p.accent).Render(prefix + items[i])
+ } else {
+ items[i] = prefix + items[i]
+ }
+ }
+ return lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Render("settings · "+m.config.BaseURL) + "\n" + strings.Join(items, "\n"))
+}
+func (m *Model) helpView(p palette) string {
+ return lipgloss.NewStyle().Padding(1).Render(lipgloss.NewStyle().Bold(true).Foreground(p.accent).Render("textlog · a quieter place for your thoughts") + "\n\n0 profile 1-5 feeds / search w write a account\n↑↓ j/k move Enter/l open h/q back g/G first/last p/n page R refresh\nTab/Shift+Tab changes profile and tag tabs.\nr reply u author profile o browser f/F follow b/B block e edit d delete ! report\n\n" + lipgloss.NewStyle().Foreground(p.muted).Render("Activity tabs require sign-in. API: "+m.config.BaseURL+"/api"))
+}