gotextlog

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

commit 0d72a4ceb3a67beecde238aeaa05df782dd9d496
parent ec965c776c93720048a0cca1b9620e8b314a5324
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Tue, 25 Aug 2026 21:38:28 -0700

Implement Charmbracelet textlog TUI

Diffstat:
Acmd/textlog/main.go | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/textlog/main_test.go | 16++++++++++++++++
Ddoc/plan/charmbracelet-tui.md | 78------------------------------------------------------------------------------
Adoc/usage.md | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ago.mod | 32++++++++++++++++++++++++++++++++
Ago.sum | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/config/config.go | 17+++++++++++------
Minternal/config/config_test.go | 13+++++++++++++
Ainternal/tui/backend.go | 284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/browser.go | 23+++++++++++++++++++++++
Ainternal/tui/model.go | 1140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/model_test.go | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/view.go | 380+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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")) +}