Repository: tonymajestro/reddit-tui Branch: main Commit: 3a594f6dce40 Files: 55 Total size: 129.6 KB Directory structure: gitextract_v95nv04n/ ├── .github/ │ └── workflows/ │ ├── build.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE.txt ├── README.md ├── client/ │ ├── cache/ │ │ ├── commentsCache.go │ │ ├── comments_cache_test.go │ │ ├── postsCache.go │ │ └── posts_cache_test.go │ ├── client.go │ ├── comments/ │ │ ├── commentsClient.go │ │ └── commentsParser.go │ ├── common/ │ │ ├── errors.go │ │ └── html.go │ ├── posts/ │ │ ├── postsClient.go │ │ └── postsParser.go │ └── url.go ├── components/ │ ├── colors/ │ │ └── colors.go │ ├── comments/ │ │ ├── commentsPage.go │ │ ├── header.go │ │ ├── keys.go │ │ ├── pager.go │ │ └── styles.go │ ├── messages/ │ │ └── messages.go │ ├── modal/ │ │ ├── error.go │ │ ├── modal.go │ │ ├── quit.go │ │ ├── render.go │ │ ├── search.go │ │ ├── spinner.go │ │ └── subredditList.go │ ├── posts/ │ │ ├── header.go │ │ ├── keys.go │ │ ├── postsPage.go │ │ └── styles.go │ ├── styles/ │ │ └── style.go │ └── tui.go ├── config/ │ ├── config.go │ └── defaultConfig.go ├── go.mod ├── go.sum ├── install.sh ├── integ_test.go ├── justfile ├── main.go ├── model/ │ ├── commentsModel.go │ └── postModel.go ├── uninstall.sh └── utils/ ├── browser.go ├── files.go ├── logger.go ├── timer.go ├── utils.go └── utils_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/build.yml ================================================ name: Build reddittui on: push: branches: - main pull_request: jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v4 with: go-version: '1.23' - name: Build reddittui run: go build -v ./... ================================================ FILE: .github/workflows/release.yml ================================================ name: Release reddittui on: push: tags: - '*' jobs: goreleaser: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-go@v4 with: go-version: stable - name: GoReleaser uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser version: "~> v2" args: release --clean env: GITHUB_TOKEN: ${{ secrets.GH_KEY }} ================================================ FILE: .gitignore ================================================ samples build/ # Added by goreleaser init: dist/ ================================================ FILE: .goreleaser.yaml ================================================ # This is an example .goreleaser.yml file with some sensible defaults. # Make sure to check the documentation at https://goreleaser.com # The lines below are called `modelines`. See `:help modeline` # Feel free to remove those if you don't want/need to use them. # yaml-language-server: $schema=https://goreleaser.com/static/schema.json # vim: set ts=2 sw=2 tw=0 fo=cnqoj version: 2 before: hooks: # You may remove this if you don't use go modules. - go mod tidy # you may remove this if you don't need go generate - go generate ./... builds: - binary: reddittui env: - CGO_ENABLED=0 goos: - linux - darwin archives: - formats: - tar.gz # this name template makes the OS and Arch compatible with the results of `uname`. name_template: >- {{ .ProjectName }}_ {{- title .Os }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} # use zip for windows archives format_overrides: - goos: windows formats: - zip changelog: sort: asc filters: exclude: - "^docs:" - "^test:" release: footer: >- --- Released by [GoReleaser](https://github.com/goreleaser/goreleaser). ================================================ FILE: LICENSE.txt ================================================ MIT License Copyright (c) 2025 Anthony Majestro Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Reddittui A lightweight terminal application for browsing Reddit from your command line. Powered by [bubbletea](https://github.com/charmbracelet/bubbletea) ## Features - **Subreddit Browsing:** Navigate through your favorite subreddits. - **Post Viewing:** Read text posts and comments. - **Keyboard Navigation:** Scroll and select posts using vim/standard keyboard shortcuts. - **Configurable**: Customize caching behavior and define subreddit filters using a configuration file ## Demo https://github.com/user-attachments/assets/40d61ef3-3a95-4a26-8c49-bec616f6ae1c ## Installation ### Git #### Prerequisites - **Git** - **Go:** Version 1.16 or newer - **Terminal:** A Unix-like terminal (Linux, macOS, or similar). - **POSIX Utilities:** The `install` command is used for installation, which is available on both Linux and macOS. Clone the repository and run the install script: ```bash git clone https://github.com/tonymajestro/reddit-tui.git reddittui cd reddittui ./install.sh ``` To remove reddittui run the uninstall script: ```bash ./uninstall.sh ``` ### Arch Arch users can install reddittui from the AUR using yay or other AUR helpers. [Pre-compiled](https://aur.archlinux.org/packages/reddit-tui-bin) and [source packages](https://aur.archlinux.org/packages/reddit-tui) are available. ```bash yay -S reddit-tui-bin ``` ```bash yay -S reddit-tui ``` ### Nix Nix users can try it in a shell or add it to their system config like this. ```bash nix-shell -p reddit-tui ``` ```nix environment.systemPackages = [ pkgs.reddit-tui ]; ``` ## Usage Run the installed binary from your preferred terminal: ```bash # Open reddittui, navigating to the home page reddittui # Open reddittui, navigating to a specific subreddit reddittui --subreddit dogs # Open reddittui, navigating to a specific post by its ID reddittui --post 1iyuce4 ``` ## Keybindings - Navigation - **h, j, k, l:** Vim movement - **left, right, up, down:** Normal movement - **g**: Go to top of page - **G**: Go to bottom of page - **s**: Switch subreddits - Posts page - **L**: Load more posts - Comments page - **o**: Open post link in browser - **c**: Collapse comments - Misc - **H:** Go to home page - **backspace**: Go back - **q, esc**: Exit reddittui ## Configuration files After running the reddittui binary, the following files will be initialized: - Configuration file: - `~/.config/reddittui/reddittui.toml` - Log file: - `~/.local/state/reddittui.log` - Cache - `~/.cache/reddittui/` Sample configuration: ```toml # Core configuration [core] bypassCache = false logLevel = "Warn" # Filter out posts containing keywords or belonging to certain subreddits [filter] subreddits = ["news", "politics"] keywords = ["pizza", "pineapple"] # Configure client timeout and cache TTL. By default, subreddit posts and comments are cached for 1 hour. [client] timeoutSeconds = 10 cacheTtlSeconds = 3600 # Configure which reddit server to use. Default is old.reddit.com but redlib servers are also supported [server] domain = "old.reddit.com" type = "old" ``` ## Redlib For enhanced privacy, private [Redlib backends](https://github.com/redlib-org/redlib) are supported. A list of Redlib servers can be found [here](https://github.com/redlib-org/redlib-instances/blob/main/instances.json). Use the following configuration to use a Redlib server instead of old.reddit.com: ```toml [server] domain = "safereddit.com" type = "redlib" ``` ## Acknowledgments Reddittui is based on the [bubbletea](https://github.com/charmbracelet/bubbletea) framework. It also takes inspiration from [circumflex](https://github.com/bensadeh/circumflex), a hackernews terminal browser. ================================================ FILE: client/cache/commentsCache.go ================================================ package cache import ( "encoding/json" "fmt" "io/fs" "log/slog" "net/url" "os" "path/filepath" "reddittui/client/common" "reddittui/model" "strings" "time" ) type CommentsCache interface { Get(path string) (model.Comments, error) Put(comments model.Comments, path string) error Clean() } type FileCommentsCache struct { BaseUrl string CacheBaseDir string } func NewFileCommentsCache(baseUrl, cacheDir string) FileCommentsCache { return FileCommentsCache{ BaseUrl: baseUrl, CacheBaseDir: cacheDir, } } // Get comments stored in cached file. // Returns comments if they are present and not expired func (f FileCommentsCache) Get(filename string) (comments model.Comments, err error) { subreddit := f.GetSubredditFromUrl(filename) if len(subreddit) == 0 { return comments, common.ErrNotFound } sanitizedFilename := url.QueryEscape(filename) + ".json" cacheFilePath := filepath.Join(f.CacheBaseDir, subreddit, sanitizedFilename) cacheFile, err := os.Open(cacheFilePath) if os.IsNotExist(err) { slog.Info("not found: " + cacheFilePath) return comments, common.ErrNotFound } else if err != nil { slog.Warn("Could not open cache file.", "error", err) return comments, common.ErrCannotOpenCacheFile } defer cacheFile.Close() decoder := json.NewDecoder(cacheFile) err = decoder.Decode(&comments) if err != nil { slog.Warn("Could not decode cached comments.", "error", err) return comments, common.ErrCannotDecodeCacheFile } if time.Now().After(comments.Expiry) { return comments, common.ErrCacheEntryExpired } return comments, nil } // Cache the comments, writing the contents to the given cache file func (f FileCommentsCache) Put(comments model.Comments, filename string) error { subreddit := f.GetSubredditFromUrl(filename) cacheDir := filepath.Join(f.CacheBaseDir, subreddit) if err := os.MkdirAll(cacheDir, 0755); err != nil { slog.Warn("Could not create subreddit comments cache directory", "error", err) return err } sanitizedFilename := url.QueryEscape(filename) + ".json" cacheFilePath := filepath.Join(cacheDir, sanitizedFilename) cacheFile, err := os.OpenFile(cacheFilePath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644) if err != nil { slog.Warn("Could not open cache file for encoding", "error", err) return common.ErrCannotOpenCacheFile } defer cacheFile.Close() commentsJson, err := json.MarshalIndent(comments, "", " ") if err != nil { slog.Warn("Could not encode comments for caching", "error", err) return common.ErrCannotEncodeCacheFile } _, err = cacheFile.Write(commentsJson) if err != nil { slog.Warn("Could not encode comments for caching", "error", err) return common.ErrCannotEncodeCacheFile } return nil } func (f FileCommentsCache) GetSubredditFromUrl(commentsUrl string) string { part := fmt.Sprintf("%s/r/", f.BaseUrl) if !strings.Contains(commentsUrl, part) { return "" } subreddit := commentsUrl[len(part):] if strings.Contains(subreddit, "/") { subreddit = subreddit[:strings.Index(subreddit, "/")] } return subreddit } func (f FileCommentsCache) Clean() { // First delete expired comments files in each subreddit directory filepath.WalkDir(f.CacheBaseDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } // Ignore directories, only look at cache comment files if d.IsDir() || filepath.Ext(path) != ".json" { return nil } cacheFile, err := os.Open(path) if err != nil { slog.Debug("Could not open cache file.", "error", err) return nil } defer cacheFile.Close() var comments model.Comments decoder := json.NewDecoder(cacheFile) err = decoder.Decode(&comments) if err != nil { slog.Debug("Could not decode cached comments.", "error", err) return nil } // Delete cached comments file if it is expired if time.Now().After(comments.Expiry) { err = os.Remove(path) if err != nil { slog.Warn("Could not delete expired cache file") return nil } } return nil }) // Wait for OS to process deleted files time.Sleep(500 * time.Millisecond) // Delete subreddit directories that are empty filepath.WalkDir(f.CacheBaseDir, func(path string, d fs.DirEntry, err error) error { if err != nil { slog.Error("cache error", "error", err) return nil } if !d.IsDir() || path == f.CacheBaseDir { return nil } dir, err := os.Open(path) if err != nil { slog.Warn("Could not open cache dir", "error", err) return filepath.SkipDir } // Get list of filenames in subreddit directory files, err := dir.Readdirnames(0) if err != nil { slog.Warn("Could not read contests of subreddit cache dir", "error", err) return filepath.SkipDir } // Delete subreddit directory if it is empty if len(files) == 0 { err = os.Remove(path) if err != nil { slog.Warn("Could not delete empty cache dir") return filepath.SkipDir } } return nil }) } type NoOpCommentsCache struct{} func NewNoOpCommentsCache() NoOpCommentsCache { return NoOpCommentsCache{} } func (n NoOpCommentsCache) Get(cacheFilePath string) (comments model.Comments, err error) { return comments, common.ErrNotFound } func (n NoOpCommentsCache) Put(comments model.Comments, cacheFilePath string) error { return nil } func (n NoOpCommentsCache) Clean() { } ================================================ FILE: client/cache/comments_cache_test.go ================================================ package cache import ( "fmt" "reddittui/client/common" "reddittui/model" "testing" "time" ) const ( testPostPoints = "5 points" testPostText = "text" testPostTimestamp = "5 mins ago" testCommentAuthor = "author" testCommentText = "comments" testCommentPoints = "5 points" testCommentTimestamp = "5 mins ago" testBaseUrl = "old.reddit.com" testCommentDepth = 0 ) func TestCommentsCacheHappyPath(t *testing.T) { cache := NewFileCommentsCache(testBaseUrl, t.TempDir()) expiry := time.Now().Add(200 * time.Millisecond).Round(time.Millisecond) expected := createTestComments(expiry) commentsUrl := generateCommentsFileUrl(testSubreddit, "happy") err := cache.Put(expected, commentsUrl) if err != nil { t.Fatalf("could not put comments in comments cache: %v", err) } got, err := cache.Get(commentsUrl) if err != nil { t.Fatalf("expected no errors getting comments from cache: %v", err) } assertComments(expected, got, t) } func TestCommentsCacheCacheNotFound(t *testing.T) { cache := NewFileCommentsCache(testBaseUrl, t.TempDir()) if _, err := cache.Get("notfound.json"); err != common.ErrNotFound { t.Fatalf("expected to not find comments in cache") } } func assertComments(expected, got model.Comments, t *testing.T) { assertVal("PostTitle", expected.PostTitle, got.PostTitle, t) assertVal("PostAuthor", expected.PostAuthor, got.PostAuthor, t) assertVal("Subreddit", expected.Subreddit, got.Subreddit, t) assertVal("PostPoints", expected.PostPoints, got.PostPoints, t) assertVal("PostText", expected.PostText, got.PostText, t) assertVal("PostTimestamp", expected.PostTimestamp, got.PostTimestamp, t) assertVal("Expiry", expected.Expiry, got.Expiry, t) if len(expected.Comments) != len(got.Comments) { t.Fatalf("expected %d comments but got %d:", len(expected.Comments), len(got.Comments)) } for i, expectedComment := range expected.Comments { gotComment := got.Comments[i] assertComment(expectedComment, gotComment, t) } if t.Failed() { t.FailNow() } } func assertComment(expectedComment, gotComment model.Comment, t *testing.T) { assertVal("Author", expectedComment.Author, gotComment.Author, t) assertVal("Text", expectedComment.Text, gotComment.Text, t) assertVal("Points", expectedComment.Points, gotComment.Points, t) assertVal("Timestamp", expectedComment.Timestamp, gotComment.Timestamp, t) assertVal("Depth", expectedComment.Depth, gotComment.Depth, t) } func createTestComment() model.Comment { return model.Comment{ Author: testCommentAuthor, Text: testCommentText, Points: testCommentPoints, Timestamp: testCommentTimestamp, Depth: testCommentDepth, } } func createTestComments(expiry time.Time) model.Comments { return model.Comments{ PostTitle: testTitle, PostAuthor: testAuthor, Subreddit: testSubreddit, PostPoints: testPostPoints, PostText: testPostUrl, PostTimestamp: testPostTimestamp, Expiry: expiry, Comments: []model.Comment{createTestComment()}, } } func generateCommentsFileUrl(subreddit, filename string) string { return fmt.Sprintf("%s/r/%s/%s", testBaseUrl, subreddit, filename) } ================================================ FILE: client/cache/postsCache.go ================================================ package cache import ( "encoding/json" "io/fs" "log/slog" "net/url" "os" "path/filepath" "reddittui/client/common" "reddittui/model" "time" ) type PostsCache interface { Get(path string) (model.Posts, error) Put(posts model.Posts, cacheFilePath string) error Clean() } type FilePostsCache struct { CacheBaseDir string } func NewFilePostsCache(cacheDir string) FilePostsCache { return FilePostsCache{CacheBaseDir: cacheDir} } // Get posts stored in cached file. // Returns posts if they are present and not expired func (f FilePostsCache) Get(filename string) (posts model.Posts, err error) { sanitizedFilename := url.QueryEscape(filename) + ".json" cacheFilePath := filepath.Join(f.CacheBaseDir, sanitizedFilename) cacheFile, err := os.Open(cacheFilePath) if os.IsNotExist(err) { return posts, common.ErrNotFound } else if err != nil { slog.Warn("Could not open cache file.", "error", err) return posts, common.ErrCannotOpenCacheFile } defer cacheFile.Close() decoder := json.NewDecoder(cacheFile) err = decoder.Decode(&posts) if err != nil { slog.Warn("Could not decode cached posts.", "error", err) return posts, common.ErrCannotDecodeCacheFile } if time.Now().After(posts.Expiry) { return posts, common.ErrCacheEntryExpired } return posts, nil } // Cache the posts, writing the contents to the given cache file func (f FilePostsCache) Put(posts model.Posts, filename string) error { sanitizedFilename := url.QueryEscape(filename) + ".json" cacheFilePath := filepath.Join(f.CacheBaseDir, sanitizedFilename) cacheFile, err := os.OpenFile(cacheFilePath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644) if err != nil { slog.Warn("Could not open cache file for encoding", "error", err) return common.ErrCannotOpenCacheFile } defer cacheFile.Close() postsJson, err := json.MarshalIndent(posts, "", " ") if err != nil { slog.Warn("Could not encode posts for caching", "error", err) return common.ErrCannotEncodeCacheFile } _, err = cacheFile.Write(postsJson) if err != nil { slog.Warn("Could not encode posts for caching", "error", err) return common.ErrCannotEncodeCacheFile } return nil } func (f FilePostsCache) Clean() { filepath.WalkDir(f.CacheBaseDir, func(path string, d fs.DirEntry, err error) error { if err != nil { slog.Error("cache error", "error", err) return nil } // Only clean up posts which are stored in root cache directory, skip everything else. if d.IsDir() { if path == f.CacheBaseDir { return nil } else { return filepath.SkipDir } } else if filepath.Ext(path) != ".json" { return nil } cacheFile, err := os.Open(path) if err != nil { slog.Debug("Could not open cache file.", "error", err) return nil } defer cacheFile.Close() var posts model.Posts decoder := json.NewDecoder(cacheFile) err = decoder.Decode(&posts) if err != nil { slog.Debug("Could not decode cached posts.", "error", err) return nil } // Delete cached posts file if it is expired if time.Now().After(posts.Expiry) { slog.Debug("Removing expired cache posts", "path", path) err = os.Remove(path) if err != nil { slog.Debug("Could not delete expired cache file", "error", err) return nil } } return nil }) } type NoOpPostsCache struct{} func NewNoOpPostsCache() NoOpPostsCache { return NoOpPostsCache{} } func (n NoOpPostsCache) Get(cacheFilePath string) (posts model.Posts, err error) { return posts, common.ErrNotFound } func (n NoOpPostsCache) Put(posts model.Posts, cacheFilePath string) error { return nil } func (f NoOpPostsCache) Clean() { } ================================================ FILE: client/cache/posts_cache_test.go ================================================ package cache import ( "os" "path/filepath" "reddittui/client/common" "reddittui/model" "strings" "testing" "time" ) const ( testTitle = "title" testDescription = "description" testAuthor = "author" testSubreddit = "subreddit" testFriendlyDate = "5 mins ago" testPostUrl = "post.url" testCommentsUrl = "comments.url" testTotalComments = "5 comments" testTotalLikes = "10 likes" testIsHome = false testAfter = "after" ) func TestPostsCacheHappyPath(t *testing.T) { cache := NewFilePostsCache(t.TempDir()) expiry := time.Now().Add(200 * time.Millisecond).Round(time.Millisecond) expected := createTestPosts(expiry) err := cache.Put(expected, "happy") if err != nil { t.Fatalf("could not put posts in posts cache: %v", err) } got, err := cache.Get("happy") if err != nil { t.Fatalf("expected no errors getting posts from cache: %v", err) } assertPosts(expected, got, t) } func TestPostsCacheNotFound(t *testing.T) { cache := NewFilePostsCache(t.TempDir()) if _, err := cache.Get("notfound.json"); err != common.ErrNotFound { t.Fatalf("expected to not find posts in cache") } } func TestPostsCacheCannotDecodePosts(t *testing.T) { cache := NewFilePostsCache(t.TempDir()) file, err := os.CreateTemp(cache.CacheBaseDir, "cannotdecode*.json") if err != nil { t.Fatalf("could not create test posts file") } defer file.Close() file.WriteString("not valid json") // Cache adds the .json extension when fetching the file from the cache // Strip it here so we don't add it twice filename := filepath.Base(file.Name()) cacheEntryName := strings.TrimSuffix(filename, filepath.Ext(filename)) if _, err = cache.Get(cacheEntryName); err != common.ErrCannotDecodeCacheFile { t.Fatalf("expected cannot decode cache entry %s, got %v", filename, err) } } func TestPostsCacheCacheExpired(t *testing.T) { cache := NewFilePostsCache(t.TempDir()) expiry := time.Now().Round(time.Millisecond) expected := createTestPosts(expiry) err := cache.Put(expected, "expired") if err != nil { t.Fatalf("could not put posts in posts cache: %v", err) } // Posts should be already expired by time we fetch them time.Sleep(100 * time.Millisecond) _, err = cache.Get("happy") if err == nil { t.Fatalf("expected no errors getting posts from cache: %v", err) } } func TestPostsCacheCleanCache(t *testing.T) { cache := NewFilePostsCache(t.TempDir()) posts1 := createTestPosts(time.Now().Round(time.Millisecond)) posts2 := createTestPosts(time.Now().Add(200 * time.Millisecond).Round(time.Millisecond)) posts1.Subreddit = "subreddit1" posts2.Subreddit = "subreddit2" cache.Put(posts1, "subreddit1") cache.Put(posts2, "subreddit2") cache.Clean() if _, err := cache.Get("subreddit1"); err != common.ErrNotFound { t.Fatal("expected expired posts subreddit1 to be cleaned from cache") } gotPosts2, err := cache.Get("subreddit2") if err != nil { t.Fatalf("unexpected error fetching subreddit2 from cache: %v", err) } assertPosts(posts2, gotPosts2, t) time.Sleep(200 * time.Millisecond) cache.Clean() if _, err := cache.Get("subreddit2"); err != common.ErrNotFound { t.Fatal("expected expired posts subreddit1 to be cleaned from cache") } } func assertPosts(expected, got model.Posts, t *testing.T) { assertVal("After", expected.After, got.After, t) assertVal("Description", expected.Description, got.Description, t) assertVal("Subreddit", expected.Subreddit, got.Subreddit, t) assertVal("IsHome", expected.IsHome, got.IsHome, t) assertVal("Expiry", expected.Expiry, got.Expiry, t) if len(expected.Posts) != len(got.Posts) { t.Fatalf("expected %d posts but got %d:", len(expected.Posts), len(got.Posts)) } for i, expectedPost := range expected.Posts { gotPost := got.Posts[i] assertPost(expectedPost, gotPost, t) } if t.Failed() { t.FailNow() } } func assertPost(expected, got model.Post, t *testing.T) { assertVal("PostTitle", expected.PostTitle, got.PostTitle, t) assertVal("Author", expected.Author, got.Author, t) assertVal("Subreddit", expected.Author, got.Author, t) assertVal("FriendlyDate", expected.FriendlyDate, got.FriendlyDate, t) assertVal("Expiry", expected.Expiry, got.Expiry, t) assertVal("PostUrl", expected.PostUrl, got.PostUrl, t) assertVal("CommentsUrl", expected.CommentsUrl, got.CommentsUrl, t) assertVal("TotalComments", expected.TotalComments, got.TotalComments, t) assertVal("TotalLikes", expected.TotalLikes, got.TotalLikes, t) } func assertVal[K comparable](context string, expected, got K, t *testing.T) { if expected != got { t.Errorf("assertion failed %s: for expected %v but got %v", context, expected, got) } } func createTestPost() model.Post { return model.Post{ PostTitle: testTitle, Author: testAuthor, Subreddit: testSubreddit, FriendlyDate: testFriendlyDate, PostUrl: testPostUrl, CommentsUrl: testCommentsUrl, TotalComments: testTotalComments, TotalLikes: testTotalLikes, } } func createTestPosts(expiry time.Time) model.Posts { post := createTestPost() posts := []model.Post{post} return model.Posts{ Description: testDescription, Subreddit: testSubreddit, IsHome: false, Posts: posts, After: testAfter, Expiry: expiry, } } ================================================ FILE: client/client.go ================================================ package client import ( "log" "log/slog" "net/http" "os" "path/filepath" "reddittui/client/cache" "reddittui/client/comments" "reddittui/client/common" "reddittui/client/posts" "reddittui/config" "reddittui/model" "reddittui/utils" "time" ) type RedditClient struct { BaseUrl string postsClient posts.RedditPostsClient commentsClient comments.RedditCommentsClient } func NewRedditClient(configuration config.Config) RedditClient { baseUrl, err := NormalizeBaseUrl(configuration.Server.Domain) if err != nil { log.Fatalf("Could not parse reddit server url: %s", configuration.Server.Domain) } // Support legacy core.ClientTimeout configuration value, use the greater of the two timeoutSeconds := max(configuration.Core.ClientTimeout, configuration.Client.TimeoutSeconds) httpClient := &http.Client{ Timeout: time.Duration(timeoutSeconds) * time.Second, } postsCache, commentsCache := InitializeCaches(baseUrl, configuration.Core.BypassCache) postsClient := posts.NewRedditPostsClient(baseUrl, httpClient, postsCache, configuration) commentsClient := comments.NewRedditCommentsClient(baseUrl, configuration.Server.Type, httpClient, commentsCache) return RedditClient{ baseUrl, postsClient, commentsClient, } } func (r RedditClient) GetHomePosts(after string) (model.Posts, error) { return r.postsClient.GetHomePosts(after) } func (r RedditClient) GetSubredditPosts(subreddit, after string) (model.Posts, error) { return r.postsClient.GetSubredditPosts(subreddit, after) } func (r RedditClient) GetComments(url string) (model.Comments, error) { return r.commentsClient.GetComments(url) } func (r RedditClient) CleanCache() { r.postsClient.Cache.Clean() r.commentsClient.Cache.Clean() } func InitializeCaches(baseUrl string, bypassCache bool) (cache.PostsCache, cache.CommentsCache) { if bypassCache { return cache.NewNoOpPostsCache(), cache.NewNoOpCommentsCache() } // read cache dir from env var cacheDir, err := utils.GetCacheDir() if err != nil { slog.Warn("Cannot open cache dir, skipping cache") return cache.NewNoOpPostsCache(), cache.NewNoOpCommentsCache() } // ensure root cache dir exists err = os.MkdirAll(cacheDir, 0755) if err != nil { slog.Warn("Cannot create root cache dir, skipping cache") return cache.NewNoOpPostsCache(), cache.NewNoOpCommentsCache() } // use root cache dir for posts postsCache := cache.NewFilePostsCache(cacheDir) // ensure comments cache dir exists commentsCacheDir := filepath.Join(cacheDir, common.CommentsCacheDirName) err = os.MkdirAll(commentsCacheDir, 0755) if err != nil { slog.Warn("Cannot create comments cache dir, skipping comments cache") return postsCache, cache.NewNoOpCommentsCache() } commentsCache := cache.NewFileCommentsCache(baseUrl, commentsCacheDir) return postsCache, commentsCache } ================================================ FILE: client/comments/commentsClient.go ================================================ package comments import ( "log/slog" "net/http" "reddittui/client/cache" "reddittui/client/common" "reddittui/model" "reddittui/utils" "regexp" "time" "golang.org/x/net/html" ) const defaultTtl = 1 * time.Hour var postTextTrimRegex = regexp.MustCompile("\n\n\n+") type RedditCommentsClient struct { BaseUrl string Client *http.Client Cache cache.CommentsCache Parser CommentsParser } func NewRedditCommentsClient(baseUrl, serverType string, httpClient *http.Client, commentsCache cache.CommentsCache) RedditCommentsClient { var parser CommentsParser switch serverType { case "old": parser = OldRedditCommentsParser{} case "redlib": parser = RedlibCommentsParser{} default: panic("Unrecognized server type in configuration: " + serverType) } return RedditCommentsClient{ BaseUrl: baseUrl, Client: httpClient, Cache: commentsCache, Parser: parser, } } func (r RedditCommentsClient) GetComments(url string) (comments model.Comments, err error) { totalTimer := utils.NewTimer("total time to retrieve comments") defer totalTimer.StopAndLog() timer := utils.NewTimer("fetching comments from cache") comments, err = r.Cache.Get(url) if err == nil { // return cached data timer.StopAndLog() return comments, nil } timer.StopAndLog() urlWithLimit := common.AddQueryParameter(url, common.LimitQueryParameter) req, err := http.NewRequest("GET", urlWithLimit, nil) if err != nil { return comments, err } req.Header.Add(common.UserAgentHeaderKey, common.UserAgentHeaderValue) timer = utils.NewTimer("fetching comments from server") res, err := r.Client.Do(req) timer.StopAndLog("url", url) if err != nil { return comments, err } if res.StatusCode != http.StatusOK { slog.Error("Error fetching comments from server", "StatusCode", res.StatusCode) return comments, common.ErrNotFound } defer res.Body.Close() timer = utils.NewTimer("parsing comments html") doc, err := html.Parse(res.Body) timer.StopAndLog() if err != nil { return comments, err } timer = utils.NewTimer("converting comments html") comments = r.Parser.ParseComments(common.HtmlNode{Node: doc}, url) comments.Expiry = time.Now().Add(defaultTtl) timer.StopAndLog() timer = utils.NewTimer("putting comments in cache") r.Cache.Put(comments, url) timer.StopAndLog() return comments, nil } ================================================ FILE: client/comments/commentsParser.go ================================================ package comments import ( "fmt" "reddittui/client/common" "reddittui/model" "reddittui/utils" "strings" "golang.org/x/net/html" ) type CommentsParser interface { ParseComments(common.HtmlNode, string) model.Comments } type OldRedditCommentsParser struct{} func (p OldRedditCommentsParser) ParseComments(root common.HtmlNode, url string) model.Comments { var commentsData model.Comments var commentsList []model.Comment commentsData.PostTitle = p.getTitle(root) commentsData.PostAuthor = p.getPostAuthor(root) commentsData.PostTimestamp = p.getPostTimestamp(root) commentsData.Subreddit = p.getSubreddit(root) commentsData.PostPoints = p.getPostPoints(root) commentsData.Comments = p.parseCommentsList(root, 0, commentsList) postText, postUrl := p.getPostContent(root) if postUrl == "" { // Self post postUrl = url } commentsData.PostText = postText commentsData.PostUrl = postUrl return commentsData } func (p OldRedditCommentsParser) parseCommentsList(node common.HtmlNode, depth int, comments []model.Comment) []model.Comment { var commentsNode common.HtmlNode commentsNode, ok := node.FindDescendant("div", "sitetable", "nestedlisting") if !ok { commentsNode, ok = node.FindDescendant("div", "sitetable", "listing") if !ok { return comments } } for c := range commentsNode.FindChildren("div", "thing", "comment") { if c.ClassContains("deleted") { // Skip deleted comments and their children // todo: figure out how to render these properly continue } entryNode, ok := c.FindChild("div", "entry") if !ok { continue } comment := p.parseCommentNode(entryNode, depth) comments = append(comments, comment) if n, ok := c.FindChild("div", "child"); ok { comments = p.parseCommentsList(n, depth+1, comments) } } return comments } func (p OldRedditCommentsParser) parseCommentNode(node common.HtmlNode, depth int) model.Comment { var comment model.Comment comment.Depth = depth if taglineNode, ok := node.FindChild("p", "tagline"); ok { if authorNode, ok := taglineNode.FindChild("a", "author"); ok { comment.Author = authorNode.Text() } // Default to 1 point if the comment is too new to show points points := "1 point" if likesNode, ok := taglineNode.FindChild("span", "score", "likes"); ok { points = likesNode.Text() } comment.Points = points if timestampNode, ok := taglineNode.FindChild("time", "live-timestamp"); ok { comment.Timestamp = timestampNode.Text() } } if usertextNode, ok := node.FindChild("form", "usertext"); ok { comment.Text = strings.TrimSpace(renderHtmlNode(usertextNode)) } return comment } func (p OldRedditCommentsParser) getTitle(root common.HtmlNode) string { for n := range root.FindDescendants("meta") { if n.GetAttr("property") == "og:title" { return n.GetAttr("content") } } return "" } func (p OldRedditCommentsParser) getPostContent(root common.HtmlNode) (content, url string) { if linkListingNode, ok := root.FindDescendant("div", "sitetable", "linklisting"); ok { // self post if mdNode, ok := linkListingNode.FindDescendant("div", "md"); ok { postText := renderHtmlNode(mdNode) // skip alb.reddit.com urls if strings.Contains(postText, "alb.reddit.com") { return "", "" } content = postTextTrimRegex.ReplaceAllString(postText, "\n\n") return content, "" } } if entry, ok := root.FindDescendant("div", "entry", "unvoted"); ok { // link post if linkNode, ok := entry.FindDescendant("a", "title"); ok { url = linkNode.GetAttr("href") // skip alb.reddit.com urls if strings.Contains(url, "alb.reddit.com") { return "", "" } content := fmt.Sprintf("%s\n\n", common.HyperLinkStyle.Render(url)) return content, url } } return "", "" } func (p OldRedditCommentsParser) getPostAuthor(root common.HtmlNode) string { if linkListingNode, ok := root.FindDescendant("div", "sitetable", "linklisting"); ok { if authorNode, ok := linkListingNode.FindDescendant("a", "author"); ok { return authorNode.Text() } } return "" } func (p OldRedditCommentsParser) getPostTimestamp(root common.HtmlNode) string { if linkListingNode, ok := root.FindDescendant("div", "sitetable", "linklisting"); ok { if timestampNode, ok := linkListingNode.FindDescendant("time", "live-timestamp"); ok { return timestampNode.Text() } } return "" } func (p OldRedditCommentsParser) getSubreddit(root common.HtmlNode) string { if spanNode, ok := root.FindDescendant("span", "pagename", "redditname"); ok { if subredditNode, ok := spanNode.FindDescendant("a"); ok { return subredditNode.Text() } } return "" } func (p OldRedditCommentsParser) getPostPoints(root common.HtmlNode) string { if linkListingNode, ok := root.FindDescendant("div", "sitetable", "linklisting"); ok { if likesNode, ok := linkListingNode.FindDescendant("div", "score", "likes"); ok { return likesNode.Text() } if unvotedNode, ok := linkListingNode.FindDescendant("div", "score", "unvoted"); ok { return unvotedNode.Text() } // Fallback to any score node if pointsNode, ok := linkListingNode.FindDescendant("div", "score"); ok { return pointsNode.Text() } } return "" } type RedlibCommentsParser struct{} func (p RedlibCommentsParser) ParseComments(root common.HtmlNode, url string) model.Comments { var ( commentsData model.Comments commentsList []model.Comment ) mainNode, ok := root.FindDescendant("main") if !ok { return commentsData } if headerNode, ok := mainNode.FindDescendant("div", "post", "highlighted"); ok { commentsData.PostAuthor = p.getPostAuthor(headerNode) commentsData.PostTimestamp = p.getPostTimestamp(headerNode) commentsData.Subreddit = p.getSubreddit(headerNode) } commentsData.PostTitle = p.getTitle(root) commentsData.PostPoints = p.getPostPoints(mainNode) commentsData.Comments = p.parseCommentsList(mainNode, 0, commentsList) postText, postUrl := p.getPostContent(mainNode) if postUrl == "" { // Self post postUrl = url } commentsData.PostText = postText commentsData.PostUrl = postUrl return commentsData } func (p RedlibCommentsParser) getTitle(root common.HtmlNode) string { titleNode, ok := root.FindDescendant("title") if !ok { return "" } // Strip subreddit from title title := titleNode.Text() index := strings.Index(title, "- r/") if index < 0 { return title } return strings.TrimSpace(title[:index]) } func (p RedlibCommentsParser) getPostAuthor(root common.HtmlNode) string { authorNode, ok := root.FindDescendant("a", "post_author") if !ok { return "" } author := authorNode.Text() if len(author) > 2 && author[:2] == "u/" { author = author[2:] } return author } func (p RedlibCommentsParser) getPostTimestamp(root common.HtmlNode) string { timestampNode, ok := root.FindDescendant("span", "created") if !ok { return "" } return timestampNode.Text() } func (p RedlibCommentsParser) getSubreddit(root common.HtmlNode) string { subredditNode, ok := root.FindDescendant("a", "post_subreddit") if !ok { return "" } return subredditNode.Text() } func (p RedlibCommentsParser) getPostPoints(root common.HtmlNode) string { pointsNode, ok := root.FindDescendant("div", "post_score") if !ok { return "" } return strings.TrimSpace(pointsNode.Text()) } func (p RedlibCommentsParser) getPostContent(root common.HtmlNode) (content, url string) { // self post if postBodyNode, ok := root.FindDescendant("div", "post_body"); ok { if mdNode, ok := postBodyNode.FindDescendant("div", "md"); ok { postText := renderHtmlNode(mdNode) content = postTextTrimRegex.ReplaceAllString(postText, "\n\n") return content, "" } } // link post for linkNode := range root.FindChildren("a") { if linkNode.GetAttr("id") == "post_url" { url = linkNode.GetAttr("href") content := fmt.Sprintf("%s\n\n", common.HyperLinkStyle.Render(url)) return content, url } } return "", "" } func (p RedlibCommentsParser) parseCommentsList(root common.HtmlNode, depth int, comments []model.Comment) []model.Comment { for threadNode := range root.FindDescendants("div", "thread") { comments = p.parseThread(threadNode, depth, comments) } return comments } func (p RedlibCommentsParser) parseThread(root common.HtmlNode, depth int, comments []model.Comment) []model.Comment { commentNode, ok := root.FindChild("div", "comment") if !ok { return comments } comment := p.parseCommentNode(commentNode, depth) comments = append(comments, comment) if n, ok := commentNode.FindDescendant("blockquote", "replies"); ok { comments = p.parseThread(n, depth+1, comments) } return comments } func (p RedlibCommentsParser) parseCommentNode(node common.HtmlNode, depth int) model.Comment { var comment model.Comment comment.Depth = depth if leftNode, ok := node.FindDescendant("div", "comment_left"); ok { if scoreNode, ok := leftNode.FindChild("p", "comment_score"); ok { points := "1 point" if scoreNode.GetAttr("title") != "Hidden" { points = utils.GetSingularPlural(strings.TrimSpace(scoreNode.Text()), "point", "points") } comment.Points = strings.TrimSpace(points) } } if rightNode, ok := node.FindDescendant("details", "comment_right"); ok { if authorNode, ok := rightNode.FindDescendant("a", "comment_author"); ok { author := authorNode.Text() if len(author) > 2 && author[:2] == "u/" { author = author[2:] } comment.Author = author } if timestampNode, ok := rightNode.FindDescendant("a", "created"); ok { comment.Timestamp = timestampNode.Text() } if commentBodyNode, ok := node.FindDescendant("div", "md"); ok { commentText := strings.TrimSpace(renderHtmlNode(commentBodyNode)) comment.Text = postTextTrimRegex.ReplaceAllString(commentText, "\n\n") } } return comment } func renderHtmlNode(node common.HtmlNode) string { var content strings.Builder for child := range node.ChildNodes() { cNode := common.HtmlNode{Node: child} var nodeResults strings.Builder renderHtmlNodeHelper(cNode, &nodeResults) content.WriteString(nodeResults.String()) content.WriteString("\n") } return content.String() } func renderHtmlNodeHelper(node common.HtmlNode, results *strings.Builder) { if node.Type == html.TextNode { results.WriteString(node.Data) } else if node.Tag() == "a" { results.WriteString(common.RenderAnchor(node)) return } else if node.Tag() == "li" { results.WriteString(node.Text()) return } for child := range node.ChildNodes() { renderHtmlNodeHelper(common.HtmlNode{Node: child}, results) } } ================================================ FILE: client/common/errors.go ================================================ package common import "errors" var ( ErrCacheEntryExpired = errors.New("entry is expired") ErrCannotLoadPosts = errors.New("cannot load posts") ErrNotFound = errors.New("not found") ErrCannotOpenCacheFile = errors.New("cannot open cache file") ErrCannotEncodeCacheFile = errors.New("cannot encode cache file") ErrCannotDecodeCacheFile = errors.New("cannot decode cache file") ) ================================================ FILE: client/common/html.go ================================================ package common import ( "fmt" "iter" "reddittui/components/colors" "slices" "strings" "github.com/charmbracelet/lipgloss" "golang.org/x/net/html" ) const ( UserAgentHeaderKey = "User-Agent" UserAgentHeaderValue = "Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0" CacheControlHeader = "Cache-Control" CommentsCacheDirName = "comments" ) var ( HyperLinkStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Blue)).Italic(true) LinkPostTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(colors.AdaptiveColor(colors.Text)) ) const LimitQueryParameter = "limit=500" type HtmlNode struct { *html.Node } func (n HtmlNode) GetAttr(key string) string { for _, attr := range n.Attr { if attr.Key != key { continue } return attr.Val } return "" } func (n HtmlNode) Classes() []string { var classes []string class := n.GetAttr("class") for _, c := range strings.Fields(class) { classes = append(classes, strings.TrimSpace(c)) } return classes } func (n HtmlNode) Class() string { return n.GetAttr("class") } func (n HtmlNode) Id() string { return n.GetAttr("id") } func (n HtmlNode) ClassContains(classesToFind ...string) bool { for _, c := range classesToFind { if !slices.Contains(n.Classes(), strings.TrimSpace(c)) { return false } } return true } func (n HtmlNode) Text() string { for c := range n.ChildNodes() { if c.Type == html.TextNode { return c.Data } } return "" } func (n HtmlNode) Tag() string { return n.Data } func (n HtmlNode) TagEquals(tag string) bool { return n.Type == html.ElementNode && n.Data == tag } func (n HtmlNode) NodeEquals(tag string, classes ...string) bool { return n.TagEquals(tag) && n.ClassContains(classes...) } func (n HtmlNode) NodeEqualsById(tag string, id string) bool { return n.TagEquals(tag) && n.Id() == id } func (n HtmlNode) FindDescendant(tag string, classes ...string) (HtmlNode, bool) { var descendant HtmlNode for c := range n.Descendants() { descendant = HtmlNode{c} if len(classes) == 0 && descendant.TagEquals(tag) { return descendant, true } else if descendant.NodeEquals(tag, classes...) { return descendant, true } } return descendant, false } func (n HtmlNode) FindDescendantById(tag string, id string) (HtmlNode, bool) { var descendant HtmlNode for c := range n.Descendants() { descendant = HtmlNode{c} if id == "" && descendant.TagEquals(tag) { return descendant, true } else if descendant.NodeEqualsById(tag, id) { return descendant, true } } return descendant, false } func (n HtmlNode) FindDescendants(tag string, classes ...string) iter.Seq[HtmlNode] { return func(yield func(HtmlNode) bool) { for c := range n.Descendants() { childNode := HtmlNode{c} if len(classes) == 0 && childNode.TagEquals(tag) { if !yield(childNode) { return } } else if childNode.NodeEquals(tag, classes...) { if !yield(childNode) { return } } } } } func (n HtmlNode) FindChild(tag string, classes ...string) (HtmlNode, bool) { var child HtmlNode for c := range n.ChildNodes() { child = HtmlNode{c} if len(classes) == 0 && child.TagEquals(tag) { return child, true } else if child.NodeEquals(tag, classes...) { return child, true } } return child, false } func (n HtmlNode) FindChildren(tag string, classes ...string) iter.Seq[HtmlNode] { return func(yield func(HtmlNode) bool) { for c := range n.ChildNodes() { childNode := HtmlNode{c} if len(classes) == 0 && childNode.TagEquals(tag) { if !yield(childNode) { return } } else if childNode.NodeEquals(tag, classes...) { if !yield(childNode) { return } } } } } func RenderAnchor(node HtmlNode) string { var ( url = node.GetAttr("href") linkText = node.Text() ) if !strings.HasPrefix(url, "http") && !strings.HasPrefix(url, "www") { return HyperLinkStyle.Render(linkText) } else if url == linkText { return HyperLinkStyle.Render(linkText) } return fmt.Sprintf( "%s %s", linkText, HyperLinkStyle.Render(url)) } func AddQueryParameter(url, query string) string { if strings.Contains(url, "?") { return fmt.Sprintf("%s&%s", url, query) } return fmt.Sprintf("%s?%s", url, query) } ================================================ FILE: client/posts/postsClient.go ================================================ package posts import ( "fmt" "log/slog" "net/http" "reddittui/client/cache" "reddittui/client/common" "reddittui/config" "reddittui/model" "reddittui/utils" "strings" "time" "golang.org/x/net/html" ) type RedditPostsClient struct { BaseUrl string CacheTtl time.Duration Client *http.Client Cache cache.PostsCache Parser PostsParser KeywordFilters []string SubredditFilters []string } func NewRedditPostsClient( baseUrl string, httpClient *http.Client, postsCache cache.PostsCache, configuration config.Config, ) RedditPostsClient { var parser PostsParser switch strings.ToLower(configuration.Server.Type) { case "old": parser = OldRedditPostsParser{} case "redlib": parser = RedlibParser{baseUrl} default: panic("Unrecognized server type in configuration: " + configuration.Server.Type) } return RedditPostsClient{ BaseUrl: baseUrl, CacheTtl: time.Duration(configuration.Client.CacheTtlSeconds) * time.Second, Client: httpClient, Cache: postsCache, Parser: parser, KeywordFilters: configuration.Filter.Keywords, SubredditFilters: configuration.Filter.Subreddits, } } func (r RedditPostsClient) GetHomePosts(after string) (model.Posts, error) { timer := utils.NewTimer("total time to retrieve home posts") defer timer.StopAndLog() postsUrl := r.BuildPostsUrl("", after) posts, err := r.tryGetCachedPosts(postsUrl) posts.IsHome = true return posts, err } func (r RedditPostsClient) GetSubredditPosts(subreddit string, after string) (model.Posts, error) { timer := utils.NewTimer("total time to retrieve subreddit posts") defer timer.StopAndLog() postsUrl := r.BuildPostsUrl(subreddit, after) posts, err := r.tryGetCachedPosts(postsUrl) posts.Subreddit = subreddit return posts, err } // Try to get posts from cache. If they are not present, fetch them and cache the results func (r RedditPostsClient) tryGetCachedPosts(postsUrl string) (posts model.Posts, err error) { timer := utils.NewTimer("fetching posts from cache") posts, err = r.Cache.Get(postsUrl) if err == nil { // return cached data timer.StopAndLog() return r.filterPosts(posts), nil } timer.StopAndLog() timer = utils.NewTimer("getting posts from server") posts, err = r.getPosts(postsUrl) if err != nil { timer.StopAndLog() return posts, err } timer.StopAndLog() timer = utils.NewTimer("filtering posts") posts = r.filterPosts(posts) timer.StopAndLog() timer = utils.NewTimer("putting posts in cache") r.Cache.Put(posts, postsUrl) timer.StopAndLog() return posts, nil } func (r RedditPostsClient) getPosts(url string) (posts model.Posts, err error) { req, err := http.NewRequest("GET", url, nil) if err != nil { return posts, err } req.Header.Add(common.UserAgentHeaderKey, common.UserAgentHeaderValue) timer := utils.NewTimer("fetching posts from server") res, err := r.Client.Do(req) timer.StopAndLog("url", url) if err != nil { return posts, err } else if res.StatusCode != http.StatusOK { // Treat all non-200s as 404s return posts, common.ErrCannotLoadPosts } defer res.Body.Close() timer = utils.NewTimer("parsing posts html") doc, err := html.Parse(res.Body) timer.StopAndLog() if err != nil { return posts, err } timer = utils.NewTimer("converting posts html") posts = r.Parser.ParsePosts(common.HtmlNode{Node: doc}) timer.StopAndLog() if len(posts.Posts) == 0 { // if there are no posts, assume 404. // reddit redirect invalid subreddits requests to some search page instead of doing 404 slog.Warn("Subreddit not found") return posts, common.ErrNotFound } posts.Expiry = time.Now().Add(r.CacheTtl) return posts, nil } func (r RedditPostsClient) filterPosts(posts model.Posts) model.Posts { var filteredPosts []model.Post outer: for _, post := range posts.Posts { for _, keyword := range r.KeywordFilters { if strings.Contains(strings.ToLower(post.PostTitle), strings.ToLower(keyword)) { slog.Debug("filtering post", "title", post.PostTitle) continue outer } } for _, subreddit := range r.SubredditFilters { subreddit = utils.NormalizeSubreddit(subreddit) if strings.EqualFold(post.Subreddit, subreddit) { slog.Debug("filtering post", "title", post.PostTitle) continue outer } } filteredPosts = append(filteredPosts, post) } posts.Posts = filteredPosts return posts } func (r RedditPostsClient) BuildPostsUrl(subreddit, after string) string { afterParam := "" if len(after) > 0 { afterParam = fmt.Sprintf("?after=%s", after) } if len(subreddit) > 0 { return fmt.Sprintf("%s/r/%s%s", r.BaseUrl, subreddit, afterParam) } return fmt.Sprintf("%s%s", r.BaseUrl, afterParam) } ================================================ FILE: client/posts/postsParser.go ================================================ package posts import ( "log/slog" "net/url" "reddittui/client/common" "reddittui/model" "strings" ) type PostsParser interface { ParsePosts(common.HtmlNode) model.Posts } type OldRedditPostsParser struct{} func (p OldRedditPostsParser) ParsePosts(root common.HtmlNode) model.Posts { var ( posts []model.Post description string ) for d := range root.FindDescendants("div", "thing") { if d.ClassContains("promoted", "promotedlink") { // Skip ads and promotional content continue } post := p.parsePost(d) posts = append(posts, post) } // Parse description for d := range root.FindDescendants("meta") { if d.GetAttr("name") == "description" { description = d.GetAttr("content") } } // Parse url for next page of posts after := "" for d := range root.FindDescendants("div", "nav-buttons") { for a := range d.FindDescendants("a") { if strings.Contains(a.Text(), "next") { if parsed, err := url.Parse(a.GetAttr("href")); err == nil { after = parsed.Query().Get("after") } } } } modelPosts := model.Posts{ Posts: posts, Description: description, After: after, } return modelPosts } func (p OldRedditPostsParser) parsePost(n common.HtmlNode) model.Post { var post model.Post for c := range n.Descendants() { cNode := common.HtmlNode{Node: c} if cNode.NodeEquals("a", "title") { post.PostTitle = cNode.Text() post.PostUrl = cNode.GetAttr("href") } else if cNode.NodeEquals("a", "author") { post.Author = cNode.Text() } else if cNode.NodeEquals("a", "subreddit") { post.Subreddit = cNode.Text() } else if cNode.NodeEquals("time", "live-timestamp") { post.FriendlyDate = cNode.Text() } else if cNode.NodeEquals("a", "comments") { post.CommentsUrl = cNode.GetAttr("href") post.TotalComments = strings.Fields(cNode.Text())[0] } else if cNode.NodeEquals("div", "likes") { post.TotalLikes = cNode.Text() } } return post } type RedlibParser struct { BaseUrl string } func (p RedlibParser) ParsePosts(root common.HtmlNode) model.Posts { var posts model.Posts for d := range root.FindDescendants("div", "post") { post := p.parsePost(d) posts.Posts = append(posts.Posts, post) } if descriptionNode, ok := root.FindDescendantById("p", "sub_description"); ok { posts.Description = descriptionNode.Text() } return posts } func (p RedlibParser) parsePost(n common.HtmlNode) model.Post { var post model.Post for c := range n.Descendants() { cNode := common.HtmlNode{Node: c} if cNode.NodeEquals("h2", "post_title") { for postTitleSubNode := range cNode.FindChildren("a") { post.PostTitle = postTitleSubNode.Text() commentsUrl, err := p.buildUrl(postTitleSubNode.GetAttr("href")) if err != nil { slog.Debug("Error parsing comments url", "error", err) continue } post.CommentsUrl = commentsUrl } } else if cNode.NodeEquals("a", "post_author") { post.Author = cNode.Text() } else if cNode.NodeEquals("a", "post_subreddit") { post.Subreddit = cNode.Text() } else if cNode.NodeEquals("span", "created") { post.FriendlyDate = cNode.Text() } else if cNode.NodeEquals("a", "post_comments") { commentsUrl, err := p.buildUrl(cNode.GetAttr("href")) if err != nil { slog.Debug("Error parsing comments url", "error", err) continue } post.CommentsUrl = commentsUrl post.TotalComments = cNode.GetAttr("title") } else if cNode.NodeEquals("div", "post_score") { post.TotalLikes = strings.TrimSpace(cNode.Text()) } } return post } func (p RedlibParser) buildUrl(part string) (string, error) { return url.JoinPath(p.BaseUrl, part) } ================================================ FILE: client/url.go ================================================ package client import ( "net/url" ) func NormalizeBaseUrl(baseUrl string) (string, error) { parsed, err := url.Parse(baseUrl) if err != nil { return "", err } if parsed.Scheme != "https" { parsed.Scheme = "https" } url := parsed.String() if url[len(url)-1] == '/' { url = url[:len(url)-1] } return url, nil } func GetPostUrl(baseUrl, post string) (string, error) { parsed, err := url.Parse(post) if err != nil { // User passed in post ID, build URL from ID return url.JoinPath(baseUrl, post) } // User passed in url, use base URL instead of the one passed in return url.JoinPath(baseUrl, parsed.Path) } ================================================ FILE: components/colors/colors.go ================================================ package colors import "github.com/charmbracelet/lipgloss" // https://github.com/catppuccin/catppuccin type Color int const ( Red = iota Maroon Pink Orange Yellow Green Blue Purple Indigo Lavender Text Subtext Sand White ) type Palette struct { Red string Maroon string Pink string Orange string Yellow string Green string Blue string Purple string Indigo string Lavender string Text string Subtext string Sand string White string } func (p Palette) ToHex(color Color) string { switch color { case Red: return p.Red case Maroon: return p.Maroon case Pink: return p.Pink case Orange: return p.Orange case Yellow: return p.Yellow case Green: return p.Green case Blue: return p.Blue case Purple: return p.Purple case Indigo: return p.Indigo case Lavender: return p.Lavender case Text: return p.Text case Subtext: return p.Subtext case Sand: return p.Sand case White: return p.White default: return p.Text } } // catppuccin-macchiato var Dark = Palette{ Red: "#ed8796", Maroon: "#ee99a0", Pink: "#f5bde6", Orange: "#f5a97f", Yellow: "#eed49f", Green: "#a6da95", Blue: "#8aadf4", Purple: "#c6a0f6", Indigo: "#5f5fd7 ", Lavender: "#b7bdf8", Text: "#cad3f5", Subtext: "#b8c0e0", Sand: "#dddddd", White: "#ffffff", } // catppuccin-latte var Light = Palette{ Red: "#d20f39", Maroon: "#e64553", Pink: "#ea76cb", Orange: "#fe640b", Yellow: "#df8e1d", Green: "#40a02b", Blue: "#1e66f5", Purple: "#8839ef", Indigo: "#5f5fd7 ", Lavender: "#7287fd", Text: "#4c4f69", Subtext: "#5c5f77", Sand: "#dddddd", White: "#ffffff", } func AdaptiveColors(light, dark Color) lipgloss.AdaptiveColor { return lipgloss.AdaptiveColor{ Light: Light.ToHex(light), Dark: Dark.ToHex(dark), } } func AdaptiveColor(color Color) lipgloss.AdaptiveColor { return lipgloss.AdaptiveColor{ Light: Light.ToHex(color), Dark: Dark.ToHex(color), } } ================================================ FILE: components/comments/commentsPage.go ================================================ package comments import ( "log/slog" "reddittui/client" "reddittui/components/messages" "reddittui/components/styles" "reddittui/model" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) var commentsErrorText = "Could not load comments. Please try again in a few moments." type CommentsPage struct { redditClient client.RedditClient header CommentsHeader pager CommentsViewport containerStyle lipgloss.Style postUrl string focus bool } func NewCommentsPage(redditClient client.RedditClient) CommentsPage { header := NewCommentsHeader() vp := NewCommentsViewport() return CommentsPage{ redditClient: redditClient, header: header, pager: vp, containerStyle: styles.GlobalStyle, } } func (c CommentsPage) Init() tea.Cmd { return nil } func (c CommentsPage) Update(msg tea.Msg) (CommentsPage, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd if c.focus { c, cmd = c.handleFocusedMessages(msg) cmds = append(cmds, cmd) } c, cmd = c.handleGlobalMessages(msg) cmds = append(cmds, cmd) return c, tea.Batch(cmds...) } func (c CommentsPage) handleGlobalMessages(msg tea.Msg) (CommentsPage, tea.Cmd) { switch msg := msg.(type) { case messages.LoadCommentsMsg: url := string(msg) return c, c.loadComments(url) case messages.UpdateCommentsMsg: c.updateComments(model.Comments(msg)) return c, messages.LoadingComplete } return c, nil } func (c CommentsPage) handleFocusedMessages(msg tea.Msg) (CommentsPage, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch keypress := msg.String(); keypress { case "H": return c, messages.LoadHome case "escape", "backspace", "left", "h": return c, messages.GoBack case "o", "O": return c, messages.OpenUrl(c.postUrl) } } var cmd tea.Cmd c.pager, cmd = c.pager.Update(msg) return c, cmd } func (c CommentsPage) View() string { headerView := c.header.View() pagerView := c.pager.View() joined := lipgloss.JoinVertical(lipgloss.Center, headerView, pagerView) return c.containerStyle.Render(joined) } func (c *CommentsPage) SetSize(w, h int) { c.containerStyle = c.containerStyle.Width(w).Height(h) c.resizeComponents() } func (c *CommentsPage) Focus() { c.focus = true } func (c *CommentsPage) Blur() { c.focus = false } func (c *CommentsPage) resizeComponents() { var ( w = c.containerStyle.GetWidth() - c.containerStyle.GetHorizontalFrameSize() h = c.containerStyle.GetHeight() - c.containerStyle.GetVerticalFrameSize() headerHeight = lipgloss.Height(c.header.View()) pagerHeight = h - headerHeight ) c.header.SetSize(w, h) c.pager.SetSize(w, pagerHeight) } func (c *CommentsPage) loadComments(url string) tea.Cmd { return func() tea.Msg { comments, err := c.redditClient.GetComments(url) if err != nil { slog.Error(commentsErrorText, "error", err) return messages.ShowErrorModalMsg{ErrorMsg: commentsErrorText} } return messages.UpdateCommentsMsg(comments) } } func (c *CommentsPage) updateComments(comments model.Comments) { c.header.SetContent(comments) c.pager.SetContent(comments) c.postUrl = comments.PostUrl // Need to resize components when content loads so padding and margins are correct c.resizeComponents() } ================================================ FILE: components/comments/header.go ================================================ package comments import ( "fmt" "reddittui/components/colors" "reddittui/model" "reddittui/utils" "strconv" "github.com/charmbracelet/lipgloss" ) var ( headerContainerStyle = lipgloss.NewStyle().MarginBottom(2) titleStyle = lipgloss.NewStyle(). MarginBottom(1). Padding(0, 2). Height(1). Background(colors.AdaptiveColors(colors.Blue, colors.Indigo)). Foreground(colors.AdaptiveColors(colors.White, colors.Sand)) defaultDescriptionStyle = lipgloss.NewStyle(). Bold(true). Foreground(colors.AdaptiveColor(colors.Text)) ) type CommentsHeader struct { DescriptionStyle lipgloss.Style Title string Description string Author string Timestamp string Points string TotalComments int W int } func NewCommentsHeader() CommentsHeader { return CommentsHeader{DescriptionStyle: defaultDescriptionStyle} } func (h *CommentsHeader) SetSize(width, height int) { h.W = width - headerContainerStyle.GetHorizontalFrameSize() h.DescriptionStyle = h.DescriptionStyle.Width(h.W) } func (h CommentsHeader) View() string { titleView := titleStyle.Render(utils.TruncateString(h.Title, h.W)) descriptionView := h.DescriptionStyle.Render(h.Description) authorView := postAuthorStyle.Render(h.Author) timestampView := postTimestampStyle.Render(fmt.Sprintf("submitted %s by", h.Timestamp)) authorTimestampView := fmt.Sprintf("%s %s", timestampView, authorView) postPointsView := postPointsStyle.Render(utils.GetSingularPlural(h.Points, "point", "points")) totalCommentsView := totalCommentsStyle.Render(utils.GetSingularPlural(strconv.Itoa(h.TotalComments), "comment", "comments")) pointsAndCommentsView := fmt.Sprintf("%s • %s", postPointsView, totalCommentsView) joinedView := lipgloss.JoinVertical(lipgloss.Left, titleView, descriptionView, authorTimestampView, pointsAndCommentsView) return headerContainerStyle.Render(joinedView) } func (h *CommentsHeader) SetContent(comments model.Comments) { h.Title = utils.NormalizeSubreddit(comments.Subreddit) h.Description = comments.PostTitle h.Author = comments.PostAuthor h.TotalComments = len(comments.Comments) h.Timestamp = comments.PostTimestamp h.Points = comments.PostPoints } ================================================ FILE: components/comments/keys.go ================================================ package comments import "github.com/charmbracelet/bubbles/key" type viewportKeyMap struct { CursorUp key.Binding CursorDown key.Binding GoToStart key.Binding GoToEnd key.Binding OpenPost key.Binding GoHome key.Binding CollapseComments key.Binding ShowFullHelp key.Binding CloseFullHelp key.Binding Quit key.Binding ForceQuit key.Binding } var commentsKeys = viewportKeyMap{ CursorUp: key.NewBinding( key.WithKeys("up", "k"), key.WithHelp("↑/k", "up"), ), CursorDown: key.NewBinding( key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"), ), GoToStart: key.NewBinding( key.WithKeys("home", "g"), key.WithHelp("g/home", "go to start"), ), GoToEnd: key.NewBinding( key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end"), ), OpenPost: key.NewBinding( key.WithKeys("o", "O"), key.WithHelp("o", "open post"), ), GoHome: key.NewBinding( key.WithKeys("H"), key.WithHelp("H", "go home"), ), CollapseComments: key.NewBinding( key.WithKeys("c"), key.WithHelp("c", "collapse comments"), ), ShowFullHelp: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "more"), ), CloseFullHelp: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "close help"), ), Quit: key.NewBinding( key.WithKeys("q", "esc"), key.WithHelp("q", "quit"), ), ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")), } func (k viewportKeyMap) ShortHelp() []key.Binding { return []key.Binding{k.CursorUp, k.CursorDown, k.OpenPost, k.GoHome, k.ShowFullHelp} } func (k viewportKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {k.CursorUp, k.CursorDown, k.GoToStart, k.GoToEnd, k.OpenPost}, {k.GoHome, k.CollapseComments, k.Quit, k.CloseFullHelp}, } } ================================================ FILE: components/comments/pager.go ================================================ package comments import ( "fmt" "reddittui/model" "strconv" "strings" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type CommentsViewport struct { viewport viewport.Model postText string postUrl string comments []model.Comment keyMap viewportKeyMap help help.Model collapsed bool viewportLines []string w, h int } func NewCommentsViewport() CommentsViewport { return CommentsViewport{ viewport: viewport.New(0, 0), keyMap: commentsKeys, help: help.New(), collapsed: false, } } func (c CommentsViewport) Update(msg tea.Msg) (CommentsViewport, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch { case key.Matches(msg, c.keyMap.GoToStart): c.viewport.GotoTop() case key.Matches(msg, c.keyMap.GoToEnd): c.viewport.GotoBottom() case key.Matches(msg, c.keyMap.CollapseComments): c.toggleCollapseComments() case key.Matches(msg, c.keyMap.ShowFullHelp), key.Matches(msg, c.keyMap.CloseFullHelp): c.help.ShowAll = !c.help.ShowAll } } var cmd tea.Cmd c.viewport, cmd = c.viewport.Update(msg) return c, cmd } func (c CommentsViewport) View() string { viewportView := viewportStyle.Render(c.viewport.View()) helpView := c.help.View(c.keyMap) return lipgloss.JoinVertical(lipgloss.Left, viewportView, helpView) } func (c *CommentsViewport) SetSize(w, h int) { c.w = w - viewportStyle.GetHorizontalFrameSize() c.h = h c.ResizeComponents() c.SetViewportContent() } func (c *CommentsViewport) SetContent(comments model.Comments) { c.postText = comments.PostText c.postUrl = comments.PostUrl c.comments = comments.Comments c.collapsed = false c.viewport.SetYOffset(0) c.ResizeComponents() c.SetViewportContent() } func (c *CommentsViewport) ResizeComponents() { helpHeight := lipgloss.Height(c.help.View(c.keyMap)) c.viewport.Width = c.w c.viewport.Height = c.h - helpHeight - 1 } func (c *CommentsViewport) GetViewportView() string { var content strings.Builder if len(c.postText) > 0 { content.WriteString(c.postText) content.WriteString("\n") } else { content.WriteString(c.postUrl) content.WriteString("\n\n") } for i := range len(c.comments) { comment := c.comments[i] commentView := c.formatComment(comment, i) if len(commentView) > 0 { content.WriteString(commentView) content.WriteString("\n\n") } } return content.String() } func (c *CommentsViewport) SetViewportContent() { content := c.GetViewportView() c.viewport.SetContent(content) c.viewportLines = strings.Split(content, "\n") } // Format comment, adding padding to the entry according to the comment's depth func (c *CommentsViewport) formatComment(comment model.Comment, i int) string { var ( authorAndDateView string pointsView string pointsAndCollapsedHintView string paddingW = comment.Depth * 2 containerStyle = lipgloss.NewStyle().PaddingLeft(paddingW).Width(c.w - paddingW) ) if c.collapsed && comment.Depth > 0 { return "" } authorView := commentAuthorStyle.Render(comment.Author) dateView := commentDateStyle.Render(comment.Timestamp) authorAndDateView = fmt.Sprintf("%s • %s", authorView, dateView) pointsView = renderPoints(comment.Points) pointsAndCollapsedHintView = pointsView if c.collapsed { children := 0 for j := i + 1; j < len(c.comments); j++ { nextComment := c.comments[j] if nextComment.Depth == 0 { break } children++ } if children == 1 { collapsedHintView := collapsedStyle.Render("(1 comment hidden)") pointsAndCollapsedHintView = fmt.Sprintf("%s %s", pointsView, collapsedHintView) } else if children > 1 { collapsedView := collapsedStyle.Render(fmt.Sprintf("(%d comments hidden)", children)) pointsAndCollapsedHintView = fmt.Sprintf("%s %s", pointsView, collapsedView) } } joined := lipgloss.JoinVertical(lipgloss.Left, authorAndDateView, comment.Text, pointsAndCollapsedHintView) return containerStyle.Render(joined) } func renderPoints(pointsString string) string { parts := strings.Fields(pointsString) if len(parts) != 2 { return defaultPointsStyle.Render(pointsString) } if strings.Contains(parts[0], "-") { return negativePointsStyle.Render(pointsString) } else if strings.Contains(parts[0], "k") { return popularPointsStyle.Render(pointsString) } points, err := strconv.Atoi(parts[0]) if err != nil { return defaultPointsStyle.Render(pointsString) } else if points >= 1000 { return popularPointsStyle.Render(pointsString) } return defaultPointsStyle.Render(pointsString) } func (c *CommentsViewport) toggleCollapseComments() { pos, title, text := c.findAnchorComment() if pos < 0 { return } offset := pos - c.viewport.YOffset c.collapsed = !c.collapsed c.SetViewportContent() newPos := c.findComment(title, text) c.viewport.SetYOffset(newPos - offset) } // Find comment closest to the center of the screen to act as an anchor when toggling // child comments. func (c *CommentsViewport) findAnchorComment() (pos int, title string, text string) { findAnchorHelper := func(start, offset int) int { for i := start; i >= 0 && i < len(c.viewportLines); i += offset { line := c.viewportLines[i] if len(line) > 0 && line[0] == ' ' { continue } split := strings.Split(line, "•") if len(split) == 2 && strings.Contains(split[1], "ago") { return i } } return -1 } // Don't use actual center of viewport since the header takes up some amount of space and // users probably look closer to the top of the screen rather than the bottom searchStart := c.viewport.YOffset + int(float64(c.viewport.Height)*0.4) if searchStart >= len(c.viewportLines) { searchStart = 0 } // Look for the comment above and below the center of the screen. Calculate which comment is closer to // the center of the screen upPos := findAnchorHelper(searchStart, -1) downPos := findAnchorHelper(searchStart, 1) if upPos < 0 && downPos < 0 { return -1, "", "" } else if upPos >= 0 && downPos < 0 { return upPos, c.viewportLines[upPos], c.viewportLines[upPos+1] } else if upPos < 0 && downPos >= 0 { return downPos, c.viewportLines[downPos], c.viewportLines[downPos+1] } upDiff, downDiff := searchStart-upPos, downPos-searchStart if upDiff < downDiff { return upPos, c.viewportLines[upPos], c.viewportLines[upPos+1] } return downPos, c.viewportLines[downPos], c.viewportLines[downPos+1] } func (c *CommentsViewport) findComment(title, text string) int { for i := range len(c.viewportLines) - 1 { currTitle := c.viewportLines[i] currText := c.viewportLines[i+1] if currTitle == title && currText == text { return i } } return -1 } ================================================ FILE: components/comments/styles.go ================================================ package comments import ( "reddittui/components/colors" "github.com/charmbracelet/lipgloss" ) var viewportStyle = lipgloss.NewStyle().Margin(0, 2, 1, 2) var ( commentAuthorStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Blue)).Bold(true) commentDateStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Lavender)).Italic(true) commentTextStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)) popularPointsStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Purple)) defaultPointsStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Purple)) negativePointsStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Red)) collapsedStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Yellow)) ) var ( postAuthorStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Blue)) postPointsStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Purple)) totalCommentsStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Orange)) postTextStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Sand)) postTimestampStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)).Faint(true) ) ================================================ FILE: components/messages/messages.go ================================================ package messages import ( "reddittui/model" tea "github.com/charmbracelet/bubbletea" ) type ErrorModalMsg struct { ErrorMsg string OnClose tea.Cmd } type ( CleanCacheMsg struct{} GoBackMsg struct{} LoadCommentsMsg string LoadHomeMsg struct{} LoadMorePostsMsg bool LoadSubredditMsg string UpdateCommentsMsg model.Comments UpdatePostsMsg model.Posts AddMorePostsMsg model.Posts LoadingCompleteMsg struct{} OpenModalMsg struct{} ExitModalMsg struct{} ShowSpinnerModalMsg string ShowErrorModalMsg ErrorModalMsg OpenUrlMsg string ) func CleanCache() tea.Msg { return CleanCacheMsg{} } func GoBack() tea.Msg { return GoBackMsg{} } func LoadHome() tea.Msg { return LoadHomeMsg{} } func LoadMorePosts(home bool) tea.Cmd { return func() tea.Msg { return LoadMorePostsMsg(home) } } func LoadSubreddit(subreddit string) tea.Cmd { return func() tea.Msg { return LoadSubredditMsg(subreddit) } } func LoadComments(url string) tea.Cmd { return func() tea.Msg { return LoadCommentsMsg(url) } } func LoadingComplete() tea.Msg { return LoadingCompleteMsg{} } func OpenModal() tea.Msg { return OpenModalMsg{} } func ExitModal() tea.Msg { return ExitModalMsg{} } func ShowSpinnerModal(loadingMsg string) tea.Cmd { return func() tea.Msg { return ShowSpinnerModalMsg(loadingMsg) } } func ShowErrorModal(errorMsg string) tea.Cmd { return func() tea.Msg { return ShowErrorModalMsg{ErrorMsg: errorMsg} } } func ShowErrorModalWithCallback(errorMsg string, callback tea.Cmd) tea.Cmd { return func() tea.Msg { return ShowErrorModalMsg{ErrorMsg: errorMsg, OnClose: callback} } } func HideSpinnerModal() tea.Msg { return ExitModalMsg{} } func OpenUrl(url string) tea.Cmd { return func() tea.Msg { return OpenUrlMsg(url) } } ================================================ FILE: components/modal/error.go ================================================ package modal import ( "fmt" "reddittui/components/colors" "reddittui/components/messages" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) var ( defaultErrorStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Red)).Bold(true) errorMsgStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)) ) type ErrorModal struct { ErrorMsg string } func NewErrorModal() ErrorModal { return ErrorModal{} } func (e ErrorModal) Init() tea.Cmd { return nil } func (e ErrorModal) Update(msg tea.Msg) (ErrorModal, tea.Cmd) { switch msg.(type) { case tea.KeyMsg: // Press any key to exit modal return e, messages.ExitModal } return e, nil } func (e ErrorModal) View() string { defaultErrorView := defaultErrorStyle.Render("Error:") errorMsgView := errorMsgStyle.Render(e.ErrorMsg) return fmt.Sprintf("%s %s", defaultErrorView, errorMsgView) } ================================================ FILE: components/modal/modal.go ================================================ package modal import ( "reddittui/components/colors" "reddittui/components/messages" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type SessionState int const ( defaultState SessionState = iota loading searching quitting showingError ) var modalStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder(), true). BorderForeground(colors.AdaptiveColor(colors.Blue)). Padding(1, 2). Margin(1, 1) type ModalManager struct { quit QuitModal search SubredditSearchModal spinner SpinnerModal errorModal ErrorModal state SessionState style lipgloss.Style onClose tea.Cmd } func NewModalManager() ModalManager { return ModalManager{ quit: NewQuitModal(), search: NewSubredditSearchModal(), spinner: NewSpinnerModal(), errorModal: NewErrorModal(), style: modalStyle, } } func (m ModalManager) Init() tea.Cmd { return nil } func (m ModalManager) Update(msg tea.Msg) (ModalManager, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd if m.state != defaultState { m, cmd = m.handleFocusedMessages(msg) cmds = append(cmds, cmd) } m, cmd = m.handleGlobalMessages(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m ModalManager) handleGlobalMessages(msg tea.Msg) (ModalManager, tea.Cmd) { switch msg := msg.(type) { case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd case messages.ShowSpinnerModalMsg: loadingMsg := string(msg) return m, m.SetLoading(loadingMsg) case messages.ShowErrorModalMsg: return m, m.SetErrorWithCallback(msg.ErrorMsg, msg.OnClose) case tea.KeyMsg: switch msg.String() { case "esc", "q": if m.state == defaultState { return m, m.SetQuitting() } case "s", "S": return m, m.SetSearching() } } return m, nil } func (m ModalManager) handleFocusedMessages(msg tea.Msg) (ModalManager, tea.Cmd) { var cmd tea.Cmd switch m.state { case loading: m.spinner, cmd = m.spinner.Update(msg) return m, cmd case quitting: m.quit, cmd = m.quit.Update(msg) return m, cmd case searching: m.search, cmd = m.search.Update(msg) return m, cmd case showingError: m.errorModal, cmd = m.errorModal.Update(msg) return m, cmd default: return m, nil } } func (m ModalManager) View(background Viewer) string { switch m.state { case loading: return PlaceModal(m.spinner, background, lipgloss.Center, lipgloss.Center, m.style) case quitting: return PlaceModal(m.quit, background, lipgloss.Center, lipgloss.Center, m.style) case searching: return PlaceModal(m.search, background, lipgloss.Center, lipgloss.Center, m.style) case showingError: return PlaceModal(m.errorModal, background, lipgloss.Center, lipgloss.Center, m.style) default: // This sometimes happens when loading completes before the loading modal finishes rendering return "" } } func (m *ModalManager) SetSize(w, h int) { m.search.SetSize(w, h) modalSize := int((float64(w) * (2)) / 3.0) m.style = m.style.MaxWidth(modalSize) } func (m *ModalManager) Blur() tea.Cmd { m.state = defaultState m.search.Blur() onClose := m.onClose m.onClose = nil return onClose } func (m *ModalManager) SetLoading(message string) tea.Cmd { m.state = loading m.spinner.SetLoading(message) return m.spinner.Tick } func (m *ModalManager) SetSearching() tea.Cmd { m.state = searching m.search.Focus() return messages.OpenModal } func (m *ModalManager) SetQuitting() tea.Cmd { m.state = quitting return messages.OpenModal } func (m *ModalManager) SetError(errorMsg string) tea.Cmd { m.state = showingError m.errorModal.ErrorMsg = errorMsg return messages.OpenModal } func (m *ModalManager) SetErrorWithCallback(errorMsg string, onClose tea.Cmd) tea.Cmd { m.state = showingError m.onClose = onClose m.errorModal.ErrorMsg = errorMsg return messages.OpenModal } ================================================ FILE: components/modal/quit.go ================================================ package modal import ( "fmt" "reddittui/components/colors" "reddittui/components/messages" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) const ( quitMsg = "Are you sure you want to quit?" yesNoMsg = "(y/n)" ) var ( quitTitleStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)).Italic(true) quitYesNoStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)).Bold(true) ) type QuitModal struct{} func NewQuitModal() QuitModal { return QuitModal{} } func (q QuitModal) View() string { titleView := quitTitleStyle.Render(quitMsg) yesNoView := quitYesNoStyle.Render(yesNoMsg) return fmt.Sprintf("%s %s", titleView, yesNoView) } func (q QuitModal) Update(msg tea.Msg) (QuitModal, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "y", "Y", "q", "Q", "esc": return q, tea.Quit default: return q, messages.ExitModal } } return q, nil } ================================================ FILE: components/modal/render.go ================================================ package modal import ( "bytes" "strings" "github.com/charmbracelet/lipgloss" "github.com/mattn/go-runewidth" "github.com/muesli/ansi" "github.com/muesli/reflow/truncate" "github.com/muesli/termenv" ) const maxModalWidthPercentage = 0.66 // Most code in this file is taken from the following codebases (modals are not currently supported in charmbracelet) // // https://github.com/mrusme/neonmodem/blob/a237769cbfc526bec380d90e4a3b873fbb47e77c/ui/helpers/overlay.go#L37 // https://github.com/charmbracelet/lipgloss/pull/102 type Viewer interface { View() string } // func Place(fg, bg string, xPos, yPos lipgloss.Position) string { func PlaceModal(foreground, background Viewer, xPos, yPos lipgloss.Position, modalStyle lipgloss.Style) string { var ( x int y int initFg = modalStyle.Render(foreground.View()) initFgWidth = lipgloss.Width(initFg) fgWidth = min(initFgWidth, int(float64(modalStyle.GetMaxWidth())*maxModalWidthPercentage)) fg = modalStyle.Width(fgWidth).Render(foreground.View()) fgHeight = lipgloss.Height(fg) bg = background.View() bgWidth = lipgloss.Width(bg) bgHeight = lipgloss.Height(bg) ) switch xPos { case lipgloss.Left: x = 0 case lipgloss.Center: x = (bgWidth / 2) - (fgWidth / 2) - 1 case lipgloss.Right: x = bgWidth - fgWidth } switch yPos { case lipgloss.Top: y = 0 case lipgloss.Center: // 45% looks more pleasing than 50% for center aligned modals y = int((float64(bgHeight) * 0.45)) - (fgHeight / 2) - 1 case lipgloss.Bottom: x = bgHeight - fgHeight } return Place(x, y, fg, bg, false) } // PlaceOverlay places fg on top of bg. func Place( x, y int, fg, bg string, shadow bool, opts ...WhitespaceOption, ) string { fgLines, fgWidth := getLines(fg) bgLines, bgWidth := getLines(bg) bgHeight := len(bgLines) fgHeight := len(fgLines) if shadow { var shadowbg string = "" shadowchar := lipgloss.NewStyle(). Foreground(lipgloss.Color("#333333")). Render("░") for i := 0; i <= fgHeight; i++ { if i == 0 { shadowbg += " " + strings.Repeat(" ", fgWidth) + "\n" } else { shadowbg += " " + strings.Repeat(shadowchar, fgWidth) + "\n" } } fg = Place(0, 0, fg, shadowbg, false, opts...) fgLines, fgWidth = getLines(fg) fgHeight = len(fgLines) } if fgWidth >= bgWidth && fgHeight >= bgHeight { // FIXME: return fg or bg? return fg } // TODO: allow placement outside of the bg box? x = clamp(x, 0, bgWidth-fgWidth) y = clamp(y, 0, bgHeight-fgHeight) ws := &whitespace{} for _, opt := range opts { opt(ws) } var b strings.Builder for i, bgLine := range bgLines { if i > 0 { b.WriteByte('\n') } if i < y || i >= y+fgHeight { b.WriteString(bgLine) continue } pos := 0 if x > 0 { left := truncate.String(bgLine, uint(x)) pos = ansi.PrintableRuneWidth(left) b.WriteString(left) if pos < x { b.WriteString(ws.render(x - pos)) pos = x } } fgLine := fgLines[i-y] b.WriteString(fgLine) pos += ansi.PrintableRuneWidth(fgLine) right := cutLeft(bgLine, pos) bgWidth := ansi.PrintableRuneWidth(bgLine) rightWidth := ansi.PrintableRuneWidth(right) if rightWidth <= bgWidth-pos { b.WriteString(ws.render(bgWidth - rightWidth - pos)) } b.WriteString(right) } return b.String() } // Split a string into lines, additionally returning the size of the widest // line. func getLines(s string) (lines []string, widest int) { lines = strings.Split(s, "\n") for _, l := range lines { w := ansi.PrintableRuneWidth(l) if widest < w { widest = w } } return lines, widest } // cutLeft cuts printable characters from the left. // This function is heavily based on muesli's ansi and truncate packages. func cutLeft(s string, cutWidth int) string { var ( pos int isAnsi bool ab bytes.Buffer b bytes.Buffer ) for _, c := range s { var w int if c == ansi.Marker || isAnsi { isAnsi = true ab.WriteRune(c) if ansi.IsTerminator(c) { isAnsi = false if bytes.HasSuffix(ab.Bytes(), []byte("[0m")) { ab.Reset() } } } else { w = runewidth.RuneWidth(c) } if pos >= cutWidth { if b.Len() == 0 { if ab.Len() > 0 { b.Write(ab.Bytes()) } if pos-cutWidth > 1 { b.WriteByte(' ') continue } } b.WriteRune(c) } pos += w } return b.String() } func clamp(v, lower, upper int) int { return min(max(v, lower), upper) } type whitespace struct { style termenv.Style chars string } // Render whitespaces. func (w whitespace) render(width int) string { if w.chars == "" { w.chars = " " } r := []rune(w.chars) j := 0 b := strings.Builder{} // Cycle through runes and print them into the whitespace. for i := 0; i < width; { b.WriteRune(r[j]) j++ if j >= len(r) { j = 0 } i += ansi.PrintableRuneWidth(string(r[j])) } // Fill any extra gaps white spaces. This might be necessary if any runes // are more than one cell wide, which could leave a one-rune gap. short := width - ansi.PrintableRuneWidth(b.String()) if short > 0 { b.WriteString(strings.Repeat(" ", short)) } return w.style.Styled(b.String()) } // WhitespaceOption sets a styling rule for rendering whitespace. type WhitespaceOption func(*whitespace) ================================================ FILE: components/modal/search.go ================================================ package modal import ( "reddittui/components/colors" "reddittui/components/messages" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) const ( searchHelpText = "Choose a subreddit:" searchPlaceholder = "subreddit" defaultSearchWidth = 35 ) var ( searchHelpStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)).Italic(true) searchModelStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Purple)) ) type SubredditSearchModal struct { textinput.Model style lipgloss.Style } func NewSubredditSearchModal() SubredditSearchModal { searchTextInput := textinput.New() searchTextInput.Placeholder = searchPlaceholder searchTextInput.ShowSuggestions = true searchTextInput.SetSuggestions(subredditSuggestions) searchTextInput.CharLimit = 30 return SubredditSearchModal{ Model: searchTextInput, style: lipgloss.NewStyle(), } } func (s SubredditSearchModal) Init() tea.Cmd { return nil } func (s SubredditSearchModal) Update(msg tea.Msg) (SubredditSearchModal, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "enter": return s, messages.LoadSubreddit(s.Value()) case "esc": return s, messages.ExitModal } } var cmd tea.Cmd s.Model, cmd = s.Model.Update(msg) return s, cmd } func (s SubredditSearchModal) View() string { titleView := searchHelpStyle.Render(searchHelpText) modelView := searchModelStyle.Render(s.Model.View()) joined := lipgloss.JoinVertical(lipgloss.Left, titleView, modelView) return s.style.Render(joined) } func (s *SubredditSearchModal) SetSize(w, h int) { searchW := min(w-s.style.GetHorizontalFrameSize(), defaultSearchWidth) s.style = s.style.Width(searchW) } func (s *SubredditSearchModal) Blur() { s.Model.Blur() s.Reset() } ================================================ FILE: components/modal/spinner.go ================================================ package modal import ( "fmt" "reddittui/components/colors" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) var ( spinnerStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Purple)) spinnerTextStyle = lipgloss.NewStyle().Foreground(colors.AdaptiveColor(colors.Text)).Italic(true) ) type SpinnerModal struct { spinner.Model LoadingMessage string } func NewSpinnerModal() SpinnerModal { model := spinner.New() model.Spinner = spinner.Dot model.Style = spinnerStyle return SpinnerModal{ Model: model, } } func (s SpinnerModal) Init() tea.Cmd { return s.Tick } func (s SpinnerModal) Update(msg tea.Msg) (SpinnerModal, tea.Cmd) { var cmd tea.Cmd s.Model, cmd = s.Model.Update(msg) return s, cmd } func (s SpinnerModal) View() string { loadingTextView := spinnerTextStyle.Render(s.LoadingMessage) return fmt.Sprintf("%s %s", s.Model.View(), loadingTextView) } func (s *SpinnerModal) SetLoading(message string) { model := spinner.New() model.Spinner = spinner.Dot model.Style = spinnerStyle s.Model = model s.LoadingMessage = message } ================================================ FILE: components/modal/subredditList.go ================================================ package modal var subredditSuggestions = []string{ "15minutefood", "adviceanimals", "all", "animalsbeingbros", "animalsbeingderps", "animalsbeingjerks", "anime", "anime_irl", "apple", "art", "askreddit", "askscience", "aww", "awwducational", "backpacking", "baking", "battlestations", "beamazed", "bestof", "bikinibottomtwitter", "biology", "bitcoin", "boardgames", "bodyweightfitness", "books", "buildapc", "camping", "canada", "careerguidance", "cars", "cats", "cfb", "changemyview", "chatgpt", "chemistry", "comicbooks", "compsci", "contagiouslaughter", "cooking", "coolguides", "cozyplaces", "crappydesign", "creepy", "cryptocurrency", "dadjokes", "damnthatsinteresting", "dataisbeautiful", "dating", "dating_advice", "daytrading", "design", "destinythegame", "digitalpainting", "diwhy", "diy", "dnd", "documentaries", "drawing", "dundermifflin", "eatcheapandhealthy", "economics", "eldenring", "entertainment", "entrepreneur", "ethereum", "europe", "expectationvsreality", "explainlikeimfive", "eyebleach", "facepalm", "fantasy", "fantasyfootball", "fauxmoi", "femalefashionadvice", "fitness", "food", "foodhacks", "formula1", "fortnitebr", "frugal", "funny", "funnyanimals", "futurology", "gadgets", "gameofthrones", "games", "gaming", "gardening", "genshin_impact", "getmotivated", "gifrecipes", "gifs", "google", "hair", "hardware", "health", "healthyfood", "highqualitygifs", "history", "historymemes", "holup", "homeautomation", "homeimprovement", "homestead", "howto", "humansbeingbros", "iama", "idiotsincars", "indieheads", "interestingasfuck", "internetisbeautiful", "iphone", "itookapicture", "japantravel", "jokes", "keto", "kpop", "leagueoflegends", "learnprogramming", "lifehacks", "lifeprotips", "listentothis", "loseit", "mademesmile", "makeupaddiction", "malefashionadvice", "maliciouscompliance", "marvelmemes", "marvelstudios", "math", "maybemaybemaybe", "mealprepsunday", "meditation", "memes", "mildlyinfuriating", "mildlyinteresting", "minecraft", "minecraftmemes", "mma", "modernwarfareii", "motorcycles", "moviedetails", "movies", "music", "mypeopleneedme", "nails", "nasa", "natureisfuckinglit", "nba", "netflixbestof", "nevertellmetheodds", "news", "nfl", "nintendoswitch", "nosleep", "nostupidquestions", "nottheonion", "nutrition", "oddlysatisfying", "oddlyspecific", "offmychest", "oldschoolcool", "onepiece", "outdoors", "outoftheloop", "overwatch", "painting", "parenting", "pcgaming", "pcmasterrace", "personalfinance", "pettyrevenge", "philosophy", "photography", "photoshopbattles", "pics", "podcasts", "pokemon", "pokemongo", "politics", "popculturechat", "premierleague", "prequelmemes", "productivity", "programmerhumor", "programming", "ps4", "ps5", "psychology", "rarepuppers", "reactiongifs", "recipes", "relationship_advice", "relationshipmemes", "roadtrip", "running", "science", "sciencememes", "scifi", "shoestring", "showerthoughts", "singularity", "skincareaddiction", "slowcooking", "sneakers", "soccer", "socialskills", "solotravel", "space", "spacex", "sports", "standupshots", "starterpacks", "starwars", "steam", "stockmarket", "streetwear", "strength_training", "survival", "tattoos", "taylorswift", "technicallythetruth", "technology", "television", "teslamotors", "thriftstorehauls", "tifu", "tinder", "todayilearned", "travel", "travelhacks", "trippinthroughtime", "unexpected", "unitedkingdom", "unpopularopinion", "upliftingnews", "videos", "wallstreetbets", "watchpeopledieinside", "wearethemusicmakers", "wholesomememes", "woahdude", "woodworking", "worldnews", "writingprompts", "youshouldknow", } ================================================ FILE: components/posts/header.go ================================================ package posts import ( "reddittui/components/colors" "reddittui/utils" "github.com/charmbracelet/lipgloss" ) var ( headerContainerStyle = lipgloss.NewStyle().MarginBottom(2) titleStyle = lipgloss.NewStyle(). MarginBottom(1). Padding(0, 2). Height(1). Background(colors.AdaptiveColors(colors.Blue, colors.Indigo)). Foreground(colors.AdaptiveColors(colors.White, colors.Sand)) defaultDescriptionStyle = lipgloss.NewStyle(). Bold(true). Foreground(colors.AdaptiveColor(colors.Text)) ) type PostsHeader struct { DescriptionStyle lipgloss.Style Title string Description string W int } func NewPostsHeader() PostsHeader { return PostsHeader{DescriptionStyle: defaultDescriptionStyle} } func (h *PostsHeader) SetSize(width, height int) { h.W = width - headerContainerStyle.GetHorizontalFrameSize() h.DescriptionStyle = h.DescriptionStyle.Width(h.W) } func (h PostsHeader) View() string { titleView := titleStyle.Render(utils.TruncateString(h.Title, h.W)) descriptionView := h.DescriptionStyle.Render(h.Description) joinedView := lipgloss.JoinVertical(lipgloss.Left, titleView, descriptionView) return headerContainerStyle.Render(joinedView) } func (h *PostsHeader) SetContent(title, desc string) { h.Title = utils.NormalizeSubreddit(title) h.Description = desc } ================================================ FILE: components/posts/keys.go ================================================ package posts import "github.com/charmbracelet/bubbles/key" type postsKeyMap struct { Home key.Binding Search key.Binding Back key.Binding Load key.Binding } var postsKeys = postsKeyMap{ Home: key.NewBinding( key.WithKeys("H"), key.WithHelp("H", "home")), Search: key.NewBinding( key.WithKeys("s"), key.WithHelp("s", "subreddit search")), Back: key.NewBinding( key.WithKeys("bs"), key.WithHelp("bs", "back")), Load: key.NewBinding( key.WithKeys("L"), key.WithHelp("L", "load more posts")), } func (k postsKeyMap) ShortHelp() []key.Binding { return []key.Binding{k.Home, k.Search, k.Load} } func (k postsKeyMap) FullHelp() []key.Binding { return []key.Binding{k.Home, k.Search, k.Back, k.Load} } ================================================ FILE: components/posts/postsPage.go ================================================ package posts import ( "fmt" "log/slog" "reddittui/client" "reddittui/client/common" "reddittui/components/messages" "reddittui/components/styles" "reddittui/model" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) const ( defaultHeaderTitle = "reddit.com" defaultHeaderDescription = "The front page of the internet" postsErrorText = "Could not load posts. Please try again in a few moments." subredditNotFoundText = "Subreddit not found" ) type PostsPage struct { Subreddit string posts model.Posts redditClient client.RedditClient header PostsHeader list list.Model focus bool Home bool containerStyle lipgloss.Style } func NewPostsPage(redditClient client.RedditClient, home bool) PostsPage { items := list.New(nil, NewPostsDelegate(), 0, 0) items.SetShowTitle(false) items.SetShowStatusBar(false) items.KeyMap.NextPage.SetEnabled(false) items.KeyMap.PrevPage.SetEnabled(false) items.SetFilteringEnabled(false) items.AdditionalShortHelpKeys = postsKeys.ShortHelp items.AdditionalFullHelpKeys = postsKeys.FullHelp header := NewPostsHeader() if home { header.SetContent(defaultHeaderTitle, defaultHeaderDescription) } containerStyle := styles.GlobalStyle return PostsPage{ list: items, redditClient: redditClient, header: header, Home: home, containerStyle: containerStyle, } } func (p PostsPage) Init() tea.Cmd { return nil } func (p PostsPage) Update(msg tea.Msg) (PostsPage, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd if p.focus { p, cmd = p.handleFocusedMessages(msg) cmds = append(cmds, cmd) } p, cmd = p.handleGlobalMessages(msg) cmds = append(cmds, cmd) return p, tea.Batch(cmds...) } func (p PostsPage) handleGlobalMessages(msg tea.Msg) (PostsPage, tea.Cmd) { switch msg := msg.(type) { case messages.LoadHomeMsg: if p.Home { return p, p.loadHome() } case messages.LoadSubredditMsg: if !p.Home { subreddit := string(msg) return p, p.loadSubreddit(subreddit) } case messages.LoadMorePostsMsg: isHome := bool(msg) if p.Home == isHome { return p, p.loadMorePosts() } case messages.UpdatePostsMsg: posts := model.Posts(msg) if posts.IsHome == p.Home { p.updatePosts(posts) return p, messages.LoadingComplete } case messages.AddMorePostsMsg: posts := model.Posts(msg) if posts.IsHome == p.Home { p.addPosts(posts) return p, messages.LoadingComplete } } return p, nil } func (p PostsPage) handleFocusedMessages(msg tea.Msg) (PostsPage, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch keypress := msg.String(); keypress { case "enter", "right", "l": loadCommentsCmd := func() tea.Msg { post := p.posts.Posts[p.list.Index()] return messages.LoadCommentsMsg(post.CommentsUrl) } return p, loadCommentsCmd case "q", "Q": // Ignore q keystrokes to list.Modal. since it will default to sending a Quit message // instead of showing the quit modal. Tui component will correctly handle quit mesages return p, nil case "L": return p, messages.LoadMorePosts(p.Home) case "H": return p, messages.LoadHome case "esc", "backspace", "left", "h": return p, messages.GoBack } } var cmd tea.Cmd p.list, cmd = p.list.Update(msg) return p, cmd } func (p PostsPage) View() string { if len(p.posts.Posts) == 0 { return p.containerStyle.Render("") } headerView := p.header.View() listView := p.list.View() joined := lipgloss.JoinVertical(lipgloss.Left, headerView, listView) return p.containerStyle.Render(joined) } func (p *PostsPage) SetSize(w, h int) { p.containerStyle = p.containerStyle.Width(w).Height(h) p.resizeComponents() } func (p *PostsPage) Focus() { p.focus = true } func (p *PostsPage) Blur() { p.focus = false } func (p *PostsPage) resizeComponents() { var ( w = p.containerStyle.GetWidth() - p.containerStyle.GetHorizontalFrameSize() h = p.containerStyle.GetHeight() - p.containerStyle.GetVerticalFrameSize() listWidth = w - postsListStyle.GetHorizontalFrameSize() headerHeight = lipgloss.Height(p.header.View()) listHeight = h - headerHeight ) p.header.SetSize(w, h) p.list.SetSize(listWidth, listHeight) } func (p *PostsPage) loadHome() tea.Cmd { return func() tea.Msg { posts, err := p.redditClient.GetHomePosts("") if err != nil { slog.Error(postsErrorText, "error", err) return messages.ShowErrorModalMsg{ErrorMsg: postsErrorText} } return messages.UpdatePostsMsg(posts) } } func (p *PostsPage) loadMorePosts() tea.Cmd { return func() tea.Msg { var ( posts model.Posts err error ) if len(p.posts.After) == 0 { slog.Error(postsErrorText, "error", err) return messages.ShowErrorModalMsg{ErrorMsg: postsErrorText} } if p.posts.IsHome { posts, err = p.redditClient.GetHomePosts(p.posts.After) } else { posts, err = p.redditClient.GetSubredditPosts(p.Subreddit, p.posts.After) } if err != nil { slog.Error(postsErrorText, "error", err) return messages.ShowErrorModalMsg{ErrorMsg: postsErrorText} } return messages.AddMorePostsMsg(posts) } } func (p PostsPage) loadSubreddit(subreddit string) tea.Cmd { return func() tea.Msg { posts, err := p.redditClient.GetSubredditPosts(subreddit, "") if err == common.ErrNotFound { slog.Error(subredditNotFoundText, "error", err, "subreddit", subreddit) return messages.ShowErrorModalMsg{ErrorMsg: fmt.Sprintf("%s: %s", subredditNotFoundText, subreddit)} } else if err != nil { slog.Error(postsErrorText, "error", err) return messages.ShowErrorModalMsg{ErrorMsg: postsErrorText} } return messages.UpdatePostsMsg(posts) } } func (p *PostsPage) updatePosts(posts model.Posts) { p.posts = posts if posts.IsHome { p.header.SetContent(defaultHeaderTitle, defaultHeaderDescription) } else { p.header.SetContent(posts.Subreddit, posts.Description) p.Subreddit = posts.Subreddit } p.list.ResetSelected() var listItems []list.Item for _, p := range posts.Posts { listItems = append(listItems, p) } p.list.SetItems(listItems) // Need to set size again when content loads so padding and margins are correct p.resizeComponents() } func (p *PostsPage) addPosts(posts model.Posts) { uniqueTitles := make(map[string]bool) p.posts.Posts = append(p.posts.Posts, posts.Posts...) p.posts.After = posts.After // Merge existing posts with new posts, avoiding duplicates var listItems []list.Item for _, p := range p.posts.Posts { if _, ok := uniqueTitles[p.PostTitle]; !ok { listItems = append(listItems, p) uniqueTitles[p.PostTitle] = true } } for _, p := range posts.Posts { if _, ok := uniqueTitles[p.PostTitle]; !ok { listItems = append(listItems, p) uniqueTitles[p.PostTitle] = true } } p.list.SetItems(listItems) // Need to set size again when content loads so padding and margins are correct p.resizeComponents() } ================================================ FILE: components/posts/styles.go ================================================ package posts import ( "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/lipgloss" ) var postsListStyle = lipgloss.NewStyle().MarginRight(4) func NewPostsDelegate() list.DefaultDelegate { delegate := list.NewDefaultDelegate() listStyle := delegate.Styles listStyle.NormalTitle = listStyle.NormalTitle.Bold(false) listStyle.SelectedTitle = listStyle.SelectedTitle.Bold(true) delegate.Styles = listStyle return delegate } ================================================ FILE: components/styles/style.go ================================================ package styles import "github.com/charmbracelet/lipgloss" var GlobalStyle = lipgloss.NewStyle().Padding(1, 2) ================================================ FILE: components/tui.go ================================================ package components import ( "fmt" "log/slog" "reddittui/client" "reddittui/components/comments" "reddittui/components/messages" "reddittui/components/modal" "reddittui/components/posts" "reddittui/config" "reddittui/utils" tea "github.com/charmbracelet/bubbletea" ) const defaultLoadingMessage = "loading reddit.com..." type ( pageType int ) const ( HomePage pageType = iota SubredditPage CommentsPage ) type RedditTui struct { redditClient client.RedditClient homePage posts.PostsPage subredditPage posts.PostsPage commentsPage comments.CommentsPage modalManager modal.ModalManager popup bool initializing bool page pageType prevPage pageType loadingPage pageType initCmd tea.Cmd } func NewRedditTui(configuration config.Config, subreddit, post string) RedditTui { redditClient := client.NewRedditClient(configuration) homePage := posts.NewPostsPage(redditClient, true) subredditPage := posts.NewPostsPage(redditClient, false) commentsPage := comments.NewCommentsPage(redditClient) modalManager := modal.NewModalManager() return RedditTui{ redditClient: redditClient, homePage: homePage, subredditPage: subredditPage, commentsPage: commentsPage, modalManager: modalManager, initializing: true, initCmd: getInitCmd(redditClient.BaseUrl, subreddit, post), } } func getInitCmd(baseUrl, subreddit, post string) tea.Cmd { if len(subreddit) != 0 { return messages.LoadSubreddit(subreddit) } else if len(post) != 0 { url, err := client.GetPostUrl(baseUrl, post) if err != nil { panic(fmt.Sprintf("Could not load post %s: %v", post, err)) } return messages.LoadComments(url) } else { return tea.Batch(messages.CleanCache, messages.LoadHome) } } func (r RedditTui) Init() tea.Cmd { return messages.LoadHome } func (r RedditTui) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( cmds []tea.Cmd cmd tea.Cmd ) switch msg := msg.(type) { case messages.ShowErrorModalMsg: if r.initializing && msg.OnClose == nil { slog.Error("Error during initialization") if r.loadingPage == HomePage { errorMsg := "Could not initialize reddittui. Check the logfile for details." return r, messages.ShowErrorModalWithCallback(errorMsg, tea.Quit) } var errorMsg string if r.loadingPage == SubredditPage { errorMsg = "Error loading subreddit. Returning to home page..." } else { errorMsg = "Error loading post. Returning to home page..." } return r, messages.ShowErrorModalWithCallback(errorMsg, messages.LoadHome) } case messages.CleanCacheMsg: r.redditClient.CleanCache() return r, nil case messages.OpenModalMsg: r.focusModal() return r, nil case messages.LoadingCompleteMsg: cmd = r.completeLoading() return r, cmd case messages.ExitModalMsg: r.popup = false r.focusActivePage() cmd = r.modalManager.Blur() return r, cmd case messages.GoBackMsg: r.goBack() return r, nil case messages.LoadHomeMsg: if r.page == HomePage && !r.initializing { return r, r.modalManager.Blur() } r.focusModal() r.loadingPage = HomePage cmd = r.modalManager.SetLoading(defaultLoadingMessage) cmds = append(cmds, cmd) case messages.LoadSubredditMsg: subreddit := string(msg) r.focusModal() r.loadingPage = SubredditPage loadingMsg := fmt.Sprintf("loading %s...", utils.NormalizeSubreddit(subreddit)) cmd = r.modalManager.SetLoading(loadingMsg) cmds = append(cmds, cmd) case messages.LoadMorePostsMsg: r.focusModal() r.loadingPage = r.page cmd = r.modalManager.SetLoading("loading posts...") cmds = append(cmds, cmd) case messages.LoadCommentsMsg: r.focusModal() r.loadingPage = CommentsPage cmd = r.modalManager.SetLoading("loading comments...") cmds = append(cmds, cmd) case messages.OpenUrlMsg: url := string(msg) if err := utils.OpenUrl(url); err != nil { slog.Error("Error opening url in browser", "url", url, "error", err.Error()) cmd = r.modalManager.SetError(fmt.Sprintf("Could not open url %s in browser", url)) cmds = append(cmds, cmd) } case tea.WindowSizeMsg: r.homePage.SetSize(msg.Width, msg.Height) r.subredditPage.SetSize(msg.Width, msg.Height) r.commentsPage.SetSize(msg.Width, msg.Height) r.modalManager.SetSize(msg.Width, msg.Height) case tea.KeyMsg: switch msg.String() { case "ctrl+c": return r, tea.Quit } } r.modalManager, cmd = r.modalManager.Update(msg) cmds = append(cmds, cmd) r.homePage, cmd = r.homePage.Update(msg) cmds = append(cmds, cmd) r.subredditPage, cmd = r.subredditPage.Update(msg) cmds = append(cmds, cmd) r.commentsPage, cmd = r.commentsPage.Update(msg) cmds = append(cmds, cmd) return r, tea.Batch(cmds...) } func (r RedditTui) View() string { if r.popup { switch r.page { case HomePage: return r.modalManager.View(r.homePage) case SubredditPage: return r.modalManager.View(r.subredditPage) case CommentsPage: return r.modalManager.View(r.commentsPage) } } switch r.page { case HomePage: return r.homePage.View() case SubredditPage: return r.subredditPage.View() case CommentsPage: return r.commentsPage.View() } return "" } func (r *RedditTui) goBack() { switch r.page { case CommentsPage: if r.prevPage == HomePage { r.setPage(HomePage) } else { r.setPage(SubredditPage) } default: r.setPage(HomePage) } r.focusActivePage() } func (r *RedditTui) setPage(page pageType) { r.page, r.prevPage = page, r.page } func (r *RedditTui) completeLoading() tea.Cmd { initializing := r.initializing r.initializing = false r.popup = false r.setPage(r.loadingPage) r.focusActivePage() if initializing { r.initializing = false return r.initCmd } return r.modalManager.Blur() } func (r *RedditTui) focusModal() { r.popup = true r.homePage.Blur() r.subredditPage.Blur() r.commentsPage.Blur() } func (r *RedditTui) focusActivePage() { switch r.page { case HomePage: r.homePage.Focus() r.subredditPage.Blur() r.commentsPage.Blur() case SubredditPage: r.homePage.Blur() r.subredditPage.Focus() r.commentsPage.Blur() case CommentsPage: r.homePage.Blur() r.subredditPage.Blur() r.commentsPage.Focus() } } ================================================ FILE: config/config.go ================================================ package config import ( "log/slog" "os" "path/filepath" "reddittui/utils" "github.com/BurntSushi/toml" ) const ( configFilename = "reddittui.toml" defaultDomainName = "old.reddit.com" defaultServerType = "old" ) type Config struct { Core CoreConfig `toml:"core"` Filter FilterConfig `toml:"filter"` Client ClientConfig `toml:"client"` Server ServerConfig `toml:"server"` } type CoreConfig struct { BypassCache bool LogLevel string ClientTimeout int // Legacy } type FilterConfig struct { Keywords []string Subreddits []string } type ClientConfig struct { TimeoutSeconds int CacheTtlSeconds int } type ServerConfig struct { Domain string Type string } func NewConfig() Config { return Config{ Core: CoreConfig{ BypassCache: false, LogLevel: "Warn", ClientTimeout: 10, }, Server: ServerConfig{ Domain: defaultDomainName, Type: defaultServerType, }, } } func LoadConfig() (Config, error) { defaultConfig := NewConfig() configDir, err := utils.GetConfigDir() if err != nil { slog.Warn("Could not get config directory", "error", err) return defaultConfig, err } err = os.MkdirAll(configDir, 0755) if err != nil { slog.Warn("Could not make config directory", "error", err) return defaultConfig, err } configPath := filepath.Join(configDir, configFilename) configFile, err := os.Open(configPath) if os.IsNotExist(err) { createConfigFile(configPath) return defaultConfig, err } else if err != nil { slog.Warn("Could not open config file", "error", err) return defaultConfig, err } defer configFile.Close() var configFromFile Config decoder := toml.NewDecoder(configFile) meta, err := decoder.Decode(&configFromFile) if err != nil { slog.Warn("Could not decode config file", "error", err) return defaultConfig, err } mergedConfig := mergeConfig(defaultConfig, configFromFile, meta) return mergedConfig, err } // Merge right config into left func mergeConfig(left, right Config, meta toml.MetaData) Config { if meta.IsDefined("core", "bypassCache") { left.Core.BypassCache = right.Core.BypassCache } if meta.IsDefined("core", "logLevel") { left.Core.LogLevel = right.Core.LogLevel } if meta.IsDefined("core", "clientTimeout") { left.Core.ClientTimeout = right.Core.ClientTimeout } if meta.IsDefined("filter", "keywords") { left.Filter.Keywords = right.Filter.Keywords } if meta.IsDefined("filter", "subreddits") { left.Filter.Subreddits = right.Filter.Subreddits } if meta.IsDefined("client", "timeoutSeconds") { left.Client.TimeoutSeconds = right.Client.TimeoutSeconds } if meta.IsDefined("client", "cacheTtlSeconds") { left.Client.CacheTtlSeconds = right.Client.CacheTtlSeconds } if meta.IsDefined("server", "domain") { left.Server.Domain = right.Server.Domain } if meta.IsDefined("server", "type") { left.Server.Type = right.Server.Type } return left } func createConfigFile(configFilePath string) error { configFile, err := os.Create(configFilePath) if err != nil { return err } _, err = configFile.WriteString(defaultConfiguration) return err } ================================================ FILE: config/defaultConfig.go ================================================ package config const defaultConfiguration = ` # # Default configuration for reddittui. # Uncomment to configure # #[core] #bypassCache = false #logLevel = "Warn" #[filter] #keywords = ["drama"] #subreddits = ["news", "politics"] #[client] #timeoutSeconds = 10 #cacheTtlSeconds = 3600 #[server] #domain = "old.reddit.com" #type = "old" ` ================================================ FILE: go.mod ================================================ module reddittui go 1.23.4 require ( github.com/charmbracelet/bubbletea v1.2.4 github.com/charmbracelet/x/exp/teatest v0.0.0-20250303111204-ce812b082f54 github.com/muesli/reflow v0.3.0 golang.org/x/net v0.39.0 ) require ( github.com/aymanbagabas/go-udiff v0.2.0 // indirect github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b // indirect ) require ( github.com/BurntSushi/toml v1.4.0 github.com/atotto/clipboard v0.1.4 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/lipgloss v1.0.0 github.com/charmbracelet/x/ansi v0.4.5 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.2.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.16 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.2 github.com/rivo/uniseg v0.4.7 // indirect golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/text v0.24.0 // indirect ) ================================================ FILE: go.sum ================================================ github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/teatest v0.0.0-20250303111204-ce812b082f54 h1:VjFUoe3r4PNKSIiKn45jl7KL+ZYSUBh5gr+JxgvFG94= github.com/charmbracelet/x/exp/teatest v0.0.0-20250303111204-ce812b082f54/go.mod h1:ag+SpTUkiN/UuUGYPX3Ci4fR1oF3XX97PpGhiXK7i6U= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 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/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= ================================================ FILE: install.sh ================================================ #!/bin/bash set -e APP_NAME="reddittui" BUILD_DIR="build" GO_MAIN_FILE="main.go" INSTALL_DIR="/usr/local/bin" # Build reddittui echo "Building reddittui application..." mkdir -p "$BUILD_DIR" go build -o "$BUILD_DIR/$APP_NAME" "$GO_MAIN_FILE" # Install reddittui echo "Installing reddittui..." echo "Copying binary to $INSTALL_DIR (may require sudo)..." sudo install -m 0755 "$BUILD_DIR/$APP_NAME" "$INSTALL_DIR/$APP_NAME" echo "Installation complete. You can now run $APP_NAME' from your terminal." ================================================ FILE: integ_test.go ================================================ package main import ( "bytes" "os" "reddittui/components" "reddittui/config" "testing" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/exp/teatest" ) const ( testTimeout = 20 * time.Second testDomain = "old.reddit.com" testServerType = "old" ) func TestStartup(t *testing.T) { t.Logf("Testing startup...") configuration := getTestConfig() tui := components.NewRedditTui(configuration, "", "") tm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100)) t.Logf("\tVerify the loading screen shows on startup...") WaitFor(t, tm, "loading reddit.com...") t.Logf("\tVerify the home page loads...") WaitFor(t, tm, "The front page of the internet") } func TestSwitchSubreddit(t *testing.T) { t.Logf("Testing switching subreddit...") configuration := getTestConfig() tui := components.NewRedditTui(configuration, "", "") tm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100)) t.Logf("\tVerify home page loads...") WaitFor(t, tm, "The front page of the internet") t.Logf("\tVerify subreddit selection modal shows...") WaitForWithInputs(t, tm, "s", "Choose a subreddit:") t.Logf("\tVerify dogs subreddit loads...") tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune("dogs"), }) tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) WaitFor(t, tm, "r/dogs") } func TestReturnToHomePage(t *testing.T) { t.Logf("Testing returning to the home page after switching subreddits...") configuration := getTestConfig() tui := components.NewRedditTui(configuration, "", "") tm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100)) t.Logf("\tVerify home page loads...") WaitFor(t, tm, "The front page of the internet") t.Logf("\tVerify subreddit selection modal shows...") WaitForWithInputs(t, tm, "s", "Choose a subreddit:") t.Logf("\tVerify dogs subreddit loads...") tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune("dogs"), }) tm.Send(tea.KeyMsg{ Type: tea.KeyEnter, }) WaitFor(t, tm, "r/dogs ") t.Logf("\tVerify home page loads") WaitForWithInputs(t, tm, "H", "The front page of the internet") } func TestShowComments(t *testing.T) { t.Logf("Testing show post comments...") configuration := getTestConfig() tui := components.NewRedditTui(configuration, "", "") tm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100)) t.Logf("\tVerify home page loads...") WaitFor(t, tm, "The front page of the internet") t.Logf("\tVerify subreddit selection modal shows...") WaitForWithInputs(t, tm, "s", "Choose a subreddit:") t.Logf("\tVerify dogs subreddit loads...") tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune("dogs"), }) tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) WaitFor(t, tm, "r/dogs", "/r/dogs") t.Logf("\tVerify comments header loads...") time.Sleep(time.Second) WaitForWithInputs(t, tm, "l", "submitted", "ago by", "point", "comment") } func TestLoadInitialPostFromId(t *testing.T) { t.Logf("Testing loading initial post...") configuration := getTestConfig() postId := "1jgxswb" tui := components.NewRedditTui(configuration, "", postId) tm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100)) t.Logf("\tVerify comments header loads...") time.Sleep(time.Second) WaitForWithInputs(t, tm, "l", "submitted", "ago by", "point", "comment") } func TestLoadInitialPostFromUrl(t *testing.T) { t.Logf("Testing loading initial post...") configuration := getTestConfig() postUrl := "https://old.reddit.com/r/dogs/comments/1jh0yne/dog_becoming_cuddlier_as_a_senior/" tui := components.NewRedditTui(configuration, "", postUrl) tm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100)) t.Logf("\tVerify comments header loads...") time.Sleep(time.Second) WaitForWithInputs(t, tm, "l", "submitted", "ago by", "point", "comment") } func TestLoadInitialSubredditAndCanGoBack(t *testing.T) { t.Logf("Testing loading subreddit...") configuration := getTestConfig() tui := components.NewRedditTui(configuration, "dogs", "") tm := teatest.NewTestModel(t, tui, teatest.WithInitialTermSize(300, 100)) t.Logf("\tVerify dog subreddit loads...") WaitFor(t, tm, "r/dogs", "/r/dogs") time.Sleep(time.Second) // Go back t.Logf("\tVerify the home page loads...") WaitForWithInputs(t, tm, "h", "The front page of the internet") } func WaitFor(t *testing.T, tm *teatest.TestModel, messages ...string) { WaitForWithInputs(t, tm, "", messages...) } func WaitForWithInputs(t *testing.T, tm *teatest.TestModel, inputs string, messages ...string) { if len(inputs) > 0 { tm.Send(tea.KeyMsg{ Type: tea.KeyRunes, Runes: []rune(inputs), }) } teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { for _, message := range messages { if !bytes.Contains(bts, []byte(message)) { return false } } return true }, teatest.WithCheckInterval(time.Millisecond*50), teatest.WithDuration(testTimeout)) } func getTestConfig() config.Config { configuration := config.NewConfig() configuration.Core.BypassCache = true domain := os.Getenv("TEST_DOMAIN") serverType := os.Getenv("TEST_SERVER_TYPE") if len(domain) > 0 && len(serverType) > 0 { configuration.Server.Domain = domain configuration.Server.Type = serverType } return configuration } ================================================ FILE: justfile ================================================ @default: run @run: go run . test: go test -v ./... clean: rm -rf build/ rm -rf ~/.cache/reddittui/* rm -rf ~/.local/state/reddittui/* build: @echo "Building reddittui..." @echo "Creating build directory at build/..." mkdir -p build @echo "Installing dependencies..." go mod tidy @echo "Building reddittui application..." go build -o build/reddittui main.go @echo "Build complete." install: build @echo "Installing reddittui..." ./install.sh @echo "Installation complete." uninstall: clean @echo "Cleaning reddittui..." sudo rm -f /usr/local/bin/reddittui @echo "Clean complete" ================================================ FILE: main.go ================================================ package main import ( "flag" "fmt" "log/slog" "os" "reddittui/components" "reddittui/config" "reddittui/utils" tea "github.com/charmbracelet/bubbletea" ) const version = "v0.3.9" type CliArgs struct { subreddit string postId string showVersion bool } func main() { configuration, _ := config.LoadConfig() logFile, err := utils.InitLogger(configuration.Core.LogLevel) if err != nil { fmt.Fprintf(os.Stderr, "Could not open logfile: %v\n", err) } defer logFile.Close() var args CliArgs flag.StringVar(&args.postId, "post", "", "Post id") flag.StringVar(&args.subreddit, "subreddit", "", "Subreddit") flag.BoolVar(&args.showVersion, "version", false, "Version") flag.Parse() if args.showVersion { fmt.Printf("reddittui version %s\n", version) os.Exit(0) } reddit := components.NewRedditTui(configuration, args.subreddit, args.postId) p := tea.NewProgram(reddit, tea.WithAltScreen()) if _, err := p.Run(); err != nil { slog.Error("Error running reddittui, see logfile for details", "error", err) os.Exit(1) } } ================================================ FILE: model/commentsModel.go ================================================ package model import ( "fmt" "strings" "time" ) type Comment struct { Author string `json:"author"` Text string `json:"text"` Points string `json:"points"` Timestamp string `json:"timestamp"` Depth int `json:"depth"` } type Comments struct { PostTitle string `json:"title"` PostAuthor string `json:"author"` Subreddit string `json:"subreddit"` PostPoints string `json:"points"` PostText string `json:"text"` PostUrl string `json:"url"` PostTimestamp string `json:"timestamp"` Expiry time.Time `json:"expiry"` Comments []Comment `json:"comments"` } func (c Comment) Title() string { return formatDepth(c.Text, c.Depth) } func (c Comment) Description() string { desc := fmt.Sprintf("%s by %s %s", c.Points, c.Author, c.Timestamp) return formatDepth(desc, c.Depth) } func (c Comment) FilterValue() string { return c.Author } func formatDepth(s string, depth int) string { var results strings.Builder for range depth { results.WriteString(" ") } results.WriteString(s) return results.String() } ================================================ FILE: model/postModel.go ================================================ package model import ( "fmt" "strings" "time" ) type Post struct { PostTitle string `json:"title"` Author string `json:"author"` Subreddit string `json:"subreddit"` FriendlyDate string `json:"friendlyDate"` Expiry time.Time `json:"expiry"` PostUrl string `json:"postUrl"` CommentsUrl string `json:"commentsUrl"` TotalComments string `json:"totalComments"` TotalLikes string `json:"totalLikes"` } type Posts struct { Description string Subreddit string IsHome bool Posts []Post After string Expiry time.Time } func (p Post) Title() string { return fmt.Sprintf(" %s %s", p.TotalLikes, p.PostTitle) } func (p Post) Description() string { var sb strings.Builder if strings.TrimSpace(p.Subreddit) != "" { sb.WriteString(p.Subreddit) sb.WriteString(" ") } if strings.TrimSpace(p.TotalComments) == "" { fmt.Fprintf(&sb, "%d comments ", 0) } else { fmt.Fprintf(&sb, "%s comments ", p.TotalComments) } fmt.Fprintf(&sb, "submitted %s by %s", p.FriendlyDate, p.Author) return sb.String() } func (p Post) FilterValue() string { return p.PostTitle } ================================================ FILE: uninstall.sh ================================================ #!/bin/bash set -e APP_NAME="reddittui" INSTALL_DIR="/usr/local/bin" BINARY_PATH="$INSTALL_DIR/$APP_NAME" if [[ -f $BINARY_PATH ]]; then echo "Uninstalling reddittui..." echo "Removing binary from $INSTALL_DIR (may require sudo)..." sudo rm "$BINARY_PATH" echo "Uninstallation complete." else echo "reddittui is not installed. Nothing to do." fi ================================================ FILE: utils/browser.go ================================================ package utils import ( "fmt" "os/exec" "runtime" ) func OpenUrl(url string) error { switch runtime.GOOS { case "linux": return exec.Command("xdg-open", url).Start() case "windows": return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() case "darwin": return exec.Command("open", url).Start() default: return fmt.Errorf("unsupported platform") } } ================================================ FILE: utils/files.go ================================================ package utils import ( "os" "path/filepath" ) const ( appName = "reddittui" defaultConfigDir = ".config" defaultStateDir = ".local/state" defaultCacheDir = ".cache" logFileName = "reddittui.log" ) func GetConfigDir() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(homeDir, defaultConfigDir, appName), nil } func GetStateDir() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(homeDir, defaultStateDir, appName), nil } func GetCacheDir() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(homeDir, defaultCacheDir, appName), nil } func OpenLogFile() (*os.File, error) { stateDir, err := GetStateDir() if err != nil { return nil, err } err = os.Mkdir(stateDir, 0750) if err != nil && !os.IsExist(err) { return nil, err } logPath := filepath.Join(stateDir, logFileName) return os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) } func FileExists(path string) bool { _, err := os.Open(path) return os.IsNotExist(err) } ================================================ FILE: utils/logger.go ================================================ package utils import ( "log/slog" "os" "strings" ) func InitLogger(logLevel string) (*os.File, error) { var level slog.Level logFile, err := OpenLogFile() if err != nil { return nil, err } switch l := strings.ToLower(logLevel); l { case "debug": level = slog.LevelDebug case "info": level = slog.LevelInfo case "warn": level = slog.LevelWarn case "error": level = slog.LevelError default: level = slog.LevelInfo } options := slog.HandlerOptions{Level: level} logger := slog.New(slog.NewJSONHandler(logFile, &options)) slog.SetDefault(logger) return logFile, nil } ================================================ FILE: utils/timer.go ================================================ package utils import ( "fmt" "log/slog" "time" ) type Timer struct { Context string Start time.Time End time.Time time.Duration } func NewTimer(context string) Timer { return Timer{ Context: context, Start: time.Now(), } } func (t *Timer) Stop() { t.End = time.Now() t.Duration = t.End.Sub(t.Start) } func (t *Timer) StopAndLog(args ...any) { t.Stop() slogArgs := []any{ "duration", fmt.Sprintf("%d ms", t.Milliseconds()), } for _, arg := range args { slogArgs = append(slogArgs, any(arg)) } slog.Debug(t.Context, slogArgs...) } ================================================ FILE: utils/utils.go ================================================ package utils import ( "fmt" ) func NormalizeSubreddit(subreddit string) string { if subreddit == "reddit.com" { return subreddit } if len(subreddit) >= 2 && subreddit[:2] == "r/" { return subreddit } return fmt.Sprintf("r/%s", subreddit) } func TruncateString(s string, w int) string { if w <= 0 { return s } else if len(s) <= w || len(s) <= 3 { return s } return fmt.Sprintf("%s...", s[:w-3]) } func Clamp(min, max, val int) int { if val < min { return min } else if val > max { return max } return val } func GetSingularPlural(s, singular, plural string) string { if s == "1" { return fmt.Sprintf("%s %s", s, singular) } return fmt.Sprintf("%s %s", s, plural) } ================================================ FILE: utils/utils_test.go ================================================ package utils import "testing" func TestNormalizeSubreddit(t *testing.T) { tests := []struct { subreddit string want string }{ {"neovim", "r/neovim"}, {"r/neovim", "r/neovim"}, } for _, tt := range tests { got := NormalizeSubreddit(tt.subreddit) if got != tt.want { t.Errorf("got %s, want %s", got, tt.want) } } } func TestTruncateString(t *testing.T) { tests := []struct { s string width int want string }{ {"abc", 3, "abc"}, {"abcd", 4, "abcd"}, {"abcde", 4, "a..."}, {"abcdef", 5, "ab..."}, {"abcdefg", 6, "abc..."}, } for _, tt := range tests { got := TruncateString(tt.s, tt.width) if got != tt.want { t.Errorf("got %s, want %s with input %s", got, tt.want, tt.s) } } } func TestClamp(t *testing.T) { tests := []struct { min int max int val int want int }{ {0, 2, 0, 0}, {0, 2, 1, 1}, {0, 2, 2, 2}, {0, 2, 3, 2}, {0, 2, -1, 0}, {0, 10, 5, 5}, {0, 10, -5, 0}, {0, 10, 15, 10}, } for _, tt := range tests { got := Clamp(tt.min, tt.max, tt.val) if got != tt.want { t.Errorf("got %d, want %d with input: min %d, max %d, val %d", got, tt.want, tt.min, tt.max, tt.val) } } } func TestGetSingularPlural(t *testing.T) { tests := []struct { s string singular string plural string want string }{ {"0", "banana", "bananas", "0 bananas"}, {"1", "banana", "bananas", "1 banana"}, {"2", "banana", "bananas", "2 bananas"}, {"3", "banana", "bananas", "3 bananas"}, } for _, tt := range tests { got := GetSingularPlural(tt.s, tt.singular, tt.plural) if got != tt.want { t.Errorf("got %s, want %s with input: s %s, singular %s, plural %s", got, tt.want, tt.s, tt.singular, tt.plural) } } }