├── media
├── demo.gif
├── screenshot.png
└── screenshot2.png
├── .gitignore
├── main.go
├── config.yaml.example
├── ui
├── window.go
├── fallback.go
├── util.go
├── help.go
├── loading.go
├── statusline.go
├── replies.go
├── splash.go
├── profile.go
├── quickselect.go
├── notifications.go
├── cast_item.go
├── cast_details.go
├── sidebar.go
├── keybindings.go
├── publish.go
├── image.go
├── app.go
└── feed.go
├── cmd
├── siwn.html
├── cast.go
├── init.go
├── root.go
└── ssh.go
├── Makefile
├── .goreleaser.yaml
├── .github
└── workflows
│ └── release.yml
├── config
└── config.go
├── LICENSE
├── api
├── reaction.go
├── signer.go
├── feed.go
├── notifications.go
├── user.go
├── cast.go
├── client.go
└── channel.go
├── db
└── db.go
├── README.md
├── go.mod
└── go.sum
/media/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/treethought/tofui/HEAD/media/demo.gif
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | debug.log
2 | config.yaml
3 | .ssh
4 | tofui
5 |
6 | dist/
7 | .tofui
8 | .db
9 |
--------------------------------------------------------------------------------
/media/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/treethought/tofui/HEAD/media/screenshot.png
--------------------------------------------------------------------------------
/media/screenshot2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/treethought/tofui/HEAD/media/screenshot2.png
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/treethought/tofui/cmd"
5 | )
6 |
7 | func main() {
8 | cmd.Execute()
9 | return
10 |
11 | }
12 |
--------------------------------------------------------------------------------
/config.yaml.example:
--------------------------------------------------------------------------------
1 | neynar:
2 | api_key: "1234"
3 | client_id: "abcd"
4 | base_url: "https://api.neynar.com/v2/farcaster"
5 | db:
6 | dir: .db
7 | log:
8 | path: debug.log
9 | server:
10 | host: localhost
11 | http_port: 4200
12 |
--------------------------------------------------------------------------------
/ui/window.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import "sync/atomic"
4 |
5 | var (
6 | Height atomic.Int32
7 | Width atomic.Int32
8 | )
9 |
10 | func SetHeight(h int) {
11 | Height.Store(int32(h))
12 | }
13 | func GetHeight() int {
14 | return int(Height.Load())
15 | }
16 |
17 | func SetWidth(w int) {
18 | Width.Store(int32(w))
19 | }
20 | func GetWidth() int {
21 | return int(Width.Load())
22 | }
23 |
--------------------------------------------------------------------------------
/ui/fallback.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import tea "github.com/charmbracelet/bubbletea"
4 |
5 | var Fallback = FallbackModel{}
6 |
7 | type FallbackModel struct{}
8 |
9 | func (m FallbackModel) Init() tea.Cmd {
10 | return nil
11 | }
12 | func (m FallbackModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
13 | switch msg := msg.(type) {
14 | case tea.KeyMsg:
15 | if msg.String() == "q" || msg.String() == "ctrl+c" {
16 | return nil, tea.Quit
17 | }
18 | }
19 | return m, nil
20 | }
21 | func (m FallbackModel) View() string {
22 | return "Something went wrong"
23 | }
24 |
--------------------------------------------------------------------------------
/cmd/siwn.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | help:
2 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
3 |
4 | SHELL := /bin/bash
5 | DEPLOY_USER ?= root
6 |
7 | build:
8 | go build -o ./tofui
9 |
10 | clean:
11 | rm -rf ~/.tofui/db
12 |
13 | start:
14 | go run cmd/main.go run -c examples/config.yaml
15 |
16 | deploy:
17 | @echo "Deploying as user: ${DEPLOY_USER}"
18 | ssh ${DEPLOY_USER}@${DEPLOY_HOST} "sudo systemctl stop tofui"
19 | scp ./tofui ${DEPLOY_USER}@${DEPLOY_HOST}:/usr/bin/
20 | scp tofui.yaml ${DEPLOY_USER}@${DEPLOY_HOST}:/etc/tofui/config.yaml
21 | ssh ${DEPLOY_USER}@${DEPLOY_HOST} "sudo systemctl daemon-reload && sudo systemctl start tofui"
22 |
23 |
--------------------------------------------------------------------------------
/ui/util.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "log"
5 | "os/exec"
6 | "runtime"
7 |
8 | tea "github.com/charmbracelet/bubbletea"
9 | )
10 |
11 | var (
12 | EmojiLike = "❤️"
13 | EmojiEmptyLike = "🤍"
14 | EmojiRecyle = "♻️"
15 | EmojiComment = "💬"
16 | EmojiPerson = "👤"
17 | )
18 |
19 | func OpenURL(url string) tea.Cmd {
20 | return func() tea.Msg {
21 | log.Println("Opening URL: ", url)
22 | var cmd string
23 | var args []string
24 |
25 | switch runtime.GOOS {
26 | case "windows":
27 | cmd = "cmd"
28 | args = []string{"/c", "start"}
29 | case "darwin":
30 | cmd = "open"
31 | default: // "linux", "freebsd", "openbsd", "netbsd"
32 | cmd = "xdg-open"
33 | }
34 | args = append(args, url)
35 |
36 | _ = exec.Command(cmd, args...).Start()
37 | return nil
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | version: 1
2 |
3 | before:
4 | hooks:
5 | - go mod tidy
6 |
7 | builds:
8 | - env:
9 | - CGO_ENABLED=0
10 | goos:
11 | - linux
12 | - windows
13 | - darwin
14 |
15 | archives:
16 | - format: tar.gz
17 | # this name template makes the OS and Arch compatible with the results of `uname`.
18 | name_template: >-
19 | {{ .ProjectName }}_
20 | {{- title .Os }}_
21 | {{- if eq .Arch "amd64" }}x86_64
22 | {{- else if eq .Arch "386" }}i386
23 | {{- else }}{{ .Arch }}{{ end }}
24 | {{- if .Arm }}v{{ .Arm }}{{ end }}
25 | # use zip for windows archives
26 | format_overrides:
27 | - goos: windows
28 | format: zip
29 |
30 | changelog:
31 | sort: asc
32 | filters:
33 | exclude:
34 | - "^docs:"
35 | - "^test:"
36 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 |
3 | on:
4 | push:
5 | tags:
6 | - "*"
7 |
8 | permissions:
9 | contents: write
10 |
11 | jobs:
12 | goreleaser:
13 | runs-on: ubuntu-latest
14 | steps:
15 | -
16 | name: Checkout
17 | uses: actions/checkout@v4
18 | with:
19 | fetch-depth: 0
20 | -
21 | name: Set up Go
22 | uses: actions/setup-go@v5
23 | -
24 | name: Run GoReleaser
25 | uses: goreleaser/goreleaser-action@v6
26 | with:
27 | # either 'goreleaser' (default) or 'goreleaser-pro'
28 | distribution: goreleaser
29 | # 'latest', 'nightly', or a semver
30 | version: '~> v2'
31 | args: release --clean
32 | env:
33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34 |
--------------------------------------------------------------------------------
/cmd/cast.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 |
8 | tea "github.com/charmbracelet/bubbletea"
9 | "github.com/spf13/cobra"
10 |
11 | "github.com/treethought/tofui/api"
12 | "github.com/treethought/tofui/db"
13 | "github.com/treethought/tofui/ui"
14 | )
15 |
16 | var castCmd = &cobra.Command{
17 | Use: "cast",
18 | Short: "publish a cast",
19 | Run: func(cmd *cobra.Command, args []string) {
20 | defer logFile.Close()
21 | defer db.GetDB().Close()
22 | signer := api.GetSigner("local")
23 | if signer != nil {
24 | log.Println("logged in as: ", signer.Username)
25 | }
26 | if signer == nil {
27 | fmt.Println("please sign in to use this command by running `tofui`")
28 | return
29 | }
30 |
31 | app := ui.NewLocalApp(cfg, true)
32 | p := tea.NewProgram(app, tea.WithAltScreen())
33 | if _, err := p.Run(); err != nil {
34 | fmt.Printf("Alas, there's been an error: %v", err)
35 | os.Exit(1)
36 | }
37 | },
38 | }
39 |
40 | func init() {
41 | rootCmd.AddCommand(castCmd)
42 | }
43 |
--------------------------------------------------------------------------------
/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "gopkg.in/yaml.v3"
8 | )
9 |
10 | type Config struct {
11 | Log struct {
12 | Path string `yaml:"path"`
13 | } `yaml:"log"`
14 | DB struct {
15 | Dir string `yaml:"dir"`
16 | }
17 | Server struct {
18 | Host string `yaml:"host"`
19 | SSHPort int `yaml:"ssh_port"`
20 | HTTPPort int `yaml:"http_port"`
21 | CertsDir string `yaml:"certs_dir"`
22 | }
23 | Neynar struct {
24 | APIKey string `yaml:"api_key"`
25 | ClientID string `yaml:"client_id"`
26 | BaseUrl string `yaml:"base_url"`
27 | }
28 | }
29 |
30 | func ReadConfig(path string) (*Config, error) {
31 | var c Config
32 | data, err := os.ReadFile(path)
33 | if err != nil {
34 | return nil, err
35 | }
36 | if err := yaml.Unmarshal([]byte(data), &c); err != nil {
37 | return nil, err
38 | }
39 | return &c, nil
40 | }
41 |
42 | func (c *Config) BaseURL() string {
43 | if c.Server.HTTPPort == 443 {
44 | return "https://" + c.Server.Host
45 | }
46 | return fmt.Sprintf("http://%s:%d", c.Server.Host, c.Server.HTTPPort)
47 | }
48 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Cam Sweeney
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/api/reaction.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "log"
7 | )
8 |
9 | type ReactionType string
10 |
11 | const (
12 | Like ReactionType = "like"
13 | Recast ReactionType = "recast"
14 | )
15 |
16 | type ReactionRequest struct {
17 | SignerUUID string `json:"signer_uuid"`
18 | ReactionType ReactionType `json:"reaction_type"`
19 | Target string `json:"target"`
20 | }
21 |
22 | type ReactionResponse struct {
23 | Success bool
24 | Message string
25 | }
26 |
27 | func (c *Client) React(s *Signer, cast string, t ReactionType) error {
28 | if s == nil {
29 | return errors.New("signer required")
30 | }
31 |
32 | var payload = ReactionRequest{
33 | SignerUUID: s.UUID,
34 | ReactionType: t,
35 | Target: cast,
36 | }
37 |
38 | log.Println("reacting to cast: ", cast, " with type: ", t)
39 | var resp ReactionResponse
40 | if err := c.doPostInto(context.TODO(), "/reaction", payload, &resp); err != nil {
41 | log.Println("failed to react: ", err)
42 | return err
43 | }
44 | log.Println("got reaction response: ", resp)
45 |
46 | if !resp.Success {
47 | return errors.New(resp.Message)
48 | }
49 | return nil
50 | }
51 |
--------------------------------------------------------------------------------
/api/signer.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | _ "embed"
5 | "encoding/json"
6 | "fmt"
7 | "log"
8 | "sync"
9 |
10 | "github.com/dgraph-io/badger/v4"
11 |
12 | "github.com/treethought/tofui/db"
13 | )
14 |
15 | var (
16 | once sync.Once
17 | sdb *badger.DB
18 |
19 | cache = make(map[string]*Signer)
20 | mu sync.RWMutex
21 | )
22 |
23 | type Signer struct {
24 | FID uint64
25 | UUID string
26 | Username string
27 | DisplayName string
28 | PublicKey string
29 | }
30 |
31 | func SetSigner(s *Signer) {
32 | once.Do(func() {
33 | d, _ := json.Marshal(s)
34 | key := fmt.Sprintf("signer:%s", s.PublicKey)
35 | if err := db.GetDB().Set([]byte(key), d); err != nil {
36 | log.Fatal("failed to save signer: ", err)
37 | }
38 | })
39 | }
40 |
41 | func GetSigner(pk string) *Signer {
42 | mu.RLock()
43 | signer, ok := cache[pk]
44 | mu.RUnlock()
45 | if ok {
46 | return signer
47 | }
48 | key := fmt.Sprintf("signer:%s", pk)
49 | d, err := db.GetDB().Get([]byte(key))
50 | if err != nil {
51 | log.Println("no signer found in db")
52 | return nil
53 | }
54 | signer = &Signer{}
55 | if err = json.Unmarshal(d, signer); err != nil {
56 | log.Println("failed to unmarshal signer: ", err)
57 | return nil
58 | }
59 | mu.Lock()
60 | cache[pk] = signer
61 | mu.Unlock()
62 | return signer
63 | }
64 |
--------------------------------------------------------------------------------
/ui/help.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "github.com/charmbracelet/bubbles/help"
5 | "github.com/charmbracelet/bubbles/key"
6 | "github.com/charmbracelet/bubbles/viewport"
7 | tea "github.com/charmbracelet/bubbletea"
8 | )
9 |
10 | type keymap interface {
11 | ShortHelp() []key.Binding
12 | FullHelp() [][]key.Binding
13 | }
14 |
15 | type HelpView struct {
16 | app *App
17 | h help.Model
18 | vp viewport.Model
19 | full bool
20 | km keymap
21 | }
22 |
23 | func NewHelpView(app *App, km keymap) *HelpView {
24 | return &HelpView{
25 | app: app,
26 | h: help.New(),
27 | vp: viewport.Model{},
28 | km: km,
29 | }
30 | }
31 |
32 | func (m *HelpView) SetSize(w, h int) {
33 | m.vp.Width = w
34 | m.vp.Height = h
35 | }
36 |
37 | func (m *HelpView) IsFull() bool {
38 | return m.full
39 | }
40 |
41 | func (m *HelpView) SetFull(full bool) {
42 | m.full = full
43 | if m.full {
44 | m.vp.SetContent(m.h.FullHelpView(m.km.FullHelp()))
45 | return
46 | }
47 | hv := m.km.ShortHelp()
48 | m.vp.SetContent(m.h.ShortHelpView(hv))
49 | }
50 |
51 | func (m *HelpView) Init() tea.Cmd {
52 | m.SetFull(false)
53 | return nil
54 | }
55 |
56 | func (m *HelpView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
57 | vp, cmd := m.vp.Update(msg)
58 | m.vp = vp
59 | return m, cmd
60 | }
61 |
62 | func (m *HelpView) ShortView() string {
63 | hv := m.km.ShortHelp()
64 | return m.h.ShortHelpView(hv)
65 | }
66 |
67 | func (m *HelpView) View() string {
68 | return m.vp.View()
69 | }
70 |
--------------------------------------------------------------------------------
/ui/loading.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/charmbracelet/bubbles/progress"
7 | tea "github.com/charmbracelet/bubbletea"
8 | )
9 |
10 | type loadTickMsg time.Time
11 |
12 | func tickCmd() tea.Cmd {
13 | return tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg {
14 | return loadTickMsg(t)
15 | })
16 | }
17 |
18 | type Loading struct {
19 | prog *progress.Model
20 | active bool
21 | pct float64
22 | }
23 |
24 | func NewLoading() *Loading {
25 | p := progress.New()
26 | p.ShowPercentage = false
27 | return &Loading{
28 | active: true,
29 | prog: &p,
30 | }
31 | }
32 |
33 | func (m *Loading) IsActive() bool {
34 | return m.active
35 | }
36 |
37 | func (m *Loading) SetActive(v bool) {
38 | m.active = v
39 | if !m.active {
40 | m.pct = 0
41 | }
42 | return
43 | }
44 |
45 | func (m *Loading) Init() tea.Cmd {
46 | if m.active {
47 | return tickCmd()
48 | }
49 | return nil
50 | }
51 |
52 | func (m *Loading) SetSize(w, h int) {
53 | m.prog.Width = w
54 | }
55 |
56 | func (m *Loading) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
57 | switch msg := msg.(type) {
58 | case tea.WindowSizeMsg:
59 | m.prog.Width = msg.Width - 2
60 | return m, nil
61 | case loadTickMsg:
62 | if !m.active {
63 | return m, nil
64 | }
65 | m.pct = m.pct + 0.1
66 | if m.pct > 1 {
67 | m.pct = 0
68 | }
69 | return m, tickCmd()
70 | }
71 |
72 | return m, nil
73 | }
74 |
75 | func (m *Loading) View() string {
76 | if !m.active {
77 | return ""
78 | }
79 | return m.prog.ViewAs(m.pct)
80 | }
81 |
--------------------------------------------------------------------------------
/api/feed.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | )
7 |
8 | type FeedRequest struct {
9 | FeedType string
10 | FID uint64
11 | FilterType string
12 | ParentURL string
13 | FIDs []uint64
14 | Cursor string
15 | Limit uint64
16 | ViewerFID uint64
17 | }
18 |
19 | func (r *FeedRequest) opts() []RequestOption {
20 | var opts []RequestOption
21 | if r.FeedType != "" {
22 | opts = append(opts, WithQuery("feed_type", r.FeedType))
23 | }
24 | if r.FilterType != "" {
25 | opts = append(opts, WithQuery("filter_type", r.FilterType))
26 | }
27 | if r.ParentURL != "" {
28 | opts = append(opts, WithQuery("parent_url", r.ParentURL))
29 | }
30 | if r.FIDs != nil {
31 | for _, fid := range r.FIDs {
32 | opts = append(opts, WithQuery("fids", fmt.Sprintf("%d", fid)))
33 | }
34 | }
35 | if r.FeedType == "following" {
36 | opts = append(opts, WithQuery("fid", fmt.Sprintf("%d", r.FID)))
37 | }
38 | if r.Cursor != "" {
39 | opts = append(opts, WithQuery("cursor", r.Cursor))
40 | }
41 | if r.Limit != 0 {
42 | opts = append(opts, WithQuery("limit", fmt.Sprintf("%d", r.Limit)))
43 | }
44 | if r.ViewerFID != 0 {
45 | opts = append(opts, WithQuery("viewer_fid", fmt.Sprintf("%d", r.ViewerFID)))
46 | }
47 |
48 | return opts
49 | }
50 |
51 | type FeedResponse struct {
52 | Casts []*Cast
53 | }
54 |
55 | func (c *Client) GetFeed(r *FeedRequest) (*FeedResponse, error) {
56 | path := "/feed"
57 | opts := r.opts()
58 | var resp FeedResponse
59 | if err := c.doRequestInto(context.TODO(), path, &resp, opts...); err != nil {
60 | return nil, err
61 | }
62 | return &resp, nil
63 | }
64 |
--------------------------------------------------------------------------------
/ui/statusline.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | tea "github.com/charmbracelet/bubbletea"
5 | "github.com/charmbracelet/lipgloss"
6 | "github.com/mistakenelf/teacup/statusbar"
7 | )
8 |
9 | var statusStyle = NewStyle().BorderTop(true).BorderStyle(lipgloss.RoundedBorder())
10 |
11 | type StatusLine struct {
12 | app *App
13 | sb statusbar.Model
14 | help *HelpView
15 | full bool
16 | }
17 |
18 | func NewStatusLine(app *App) *StatusLine {
19 | sb := statusbar.New(
20 | statusbar.ColorConfig{
21 | Foreground: lipgloss.AdaptiveColor{Dark: "#ffffff", Light: "#ffffff"},
22 | Background: lipgloss.AdaptiveColor{Light: "#F25D94", Dark: "#483285"},
23 | },
24 | statusbar.ColorConfig{
25 | // Foreground: lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#ffffff"},
26 | // Background: lipgloss.AdaptiveColor{Light: "#3c3836", Dark: "#3c3836"},
27 | },
28 | statusbar.ColorConfig{
29 | // Foreground: lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#ffffff"},
30 | // Background: lipgloss.AdaptiveColor{Light: "#A550DF", Dark: "#A550DF"},
31 | },
32 | statusbar.ColorConfig{
33 | // Foreground: lipgloss.AdaptiveColor{Light: "#ffffff", Dark: "#ffffff"},
34 | // Background: lipgloss.AdaptiveColor{Light: "#6124DF", Dark: "#6124DF"},
35 | },
36 | )
37 | return &StatusLine{
38 | sb: sb,
39 | app: app,
40 | help: NewHelpView(app, GlobalKeyMap),
41 | full: false,
42 | }
43 | }
44 |
45 | func (m *StatusLine) SetSize(width, height int) {
46 | fx, _ := statusStyle.GetFrameSize()
47 | m.sb.SetSize(width - fx)
48 | m.sb.Height = 1
49 | }
50 |
51 | func (m *StatusLine) Init() tea.Cmd {
52 | return nil
53 | }
54 |
55 | func (m *StatusLine) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
56 | m.sb.SetContent(m.app.navname, "", "", m.help.ShortView())
57 | _, cmd := m.sb.Update(msg)
58 | return m, cmd
59 | }
60 |
61 | func (m *StatusLine) View() string {
62 | return statusStyle.Render(m.sb.View())
63 | }
64 |
--------------------------------------------------------------------------------
/api/notifications.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "time"
7 | )
8 |
9 | type NotificationsType string
10 | type CastReactionObjType string
11 |
12 | const (
13 | NotificationsTypeFollows NotificationsType = "follows"
14 | NotificationsTypeLikes NotificationsType = "likes"
15 | NotificationsTypeRecasts NotificationsType = "recasts"
16 | NotificationsTypeMention NotificationsType = "mention"
17 | NotificationsTypeReply NotificationsType = "reply"
18 |
19 | CastReactionObjTypeLikes CastReactionObjType = "likes"
20 | CastReactionObjTypeRecasts CastReactionObjType = "recasts"
21 | )
22 |
23 | type NotificationsResponse struct {
24 | Notifications []*Notification `json:"notifications"`
25 | Next struct {
26 | Cursor *string `json:"cursor"`
27 | }
28 | }
29 |
30 | type Notification struct {
31 | Object string `json:"object"`
32 | MostRecentTimestamp time.Time `json:"most_recent_timestamp"`
33 | Type NotificationsType `json:"type"`
34 | Cast *Cast `json:"cast"`
35 | Follows []FollowNotification `json:"follows"`
36 | Reactions []ReactionNotification `json:"reactions"`
37 | }
38 |
39 | type FollowNotification struct {
40 | Object string `json:"object"`
41 | User User `json:"user"`
42 | }
43 |
44 | type ReactionNotification struct {
45 | Object CastReactionObjType `json:"object"`
46 | Cast NotificationCast `json:"cast"`
47 | User User `json:"user"`
48 | }
49 |
50 | type NotificationCast struct {
51 | Cast // may be cast_dehydrated which only has hash, specifically for reactions
52 | }
53 |
54 | func (c *Client) GetNotifications(fid uint64, opts ...RequestOption) (*NotificationsResponse, error) {
55 | path := fmt.Sprintf("/notifications")
56 |
57 | opts = append(opts, WithFID(fid))
58 |
59 | var resp NotificationsResponse
60 | if err := c.doRequestInto(context.TODO(), path, &resp, opts...); err != nil {
61 | return nil, err
62 | }
63 | return &resp, nil
64 | }
65 |
--------------------------------------------------------------------------------
/cmd/init.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "log"
7 | "os"
8 | "path/filepath"
9 |
10 | "github.com/spf13/cobra"
11 | "gopkg.in/yaml.v3"
12 |
13 | "github.com/treethought/tofui/config"
14 | )
15 |
16 | var initCmd = &cobra.Command{
17 | Use: "init",
18 | Short: "init tofui config",
19 | Run: func(cmd *cobra.Command, args []string) {
20 | home, err := os.UserHomeDir()
21 | if err != nil {
22 | log.Fatal("failed to get user home directory")
23 | }
24 | fmt.Println("To use tofui locally, you will need to create a Neynar app")
25 | reader := bufio.NewReader(os.Stdin)
26 |
27 | fmt.Print("Enter Client ID (found at https://dev.neynar.com/app): \n")
28 | clientID, _ := reader.ReadString('\n')
29 | clientID = clientID[:len(clientID)-1] // Trim newline character
30 |
31 | fmt.Print("Enter API Key (found at https://dev.neynar.com/): \n")
32 | apiKey, _ := reader.ReadString('\n')
33 | apiKey = apiKey[:len(apiKey)-1] // Trim newline character
34 |
35 | cfg := &config.Config{}
36 | cfg.Neynar.ClientID = clientID
37 | cfg.Neynar.APIKey = apiKey
38 | cfg.Neynar.BaseUrl = "https://api.neynar.com/v2/farcaster"
39 | cfg.Server.Host = "localhost"
40 | cfg.Server.HTTPPort = 4200
41 | cfg.DB.Dir = filepath.Join(home, ".tofui", "db")
42 | cfg.Log.Path = filepath.Join(home, ".tofui", "debug.log")
43 |
44 | path := filepath.Join(home, ".tofui", "config.yaml")
45 |
46 | data, err := yaml.Marshal(cfg)
47 | if err != nil {
48 | log.Fatalf("error: %v", err)
49 | }
50 | err = os.MkdirAll(filepath.Dir(path), 0755)
51 | if err != nil {
52 | log.Fatalf("failed to create config directory: %v", err)
53 | }
54 | if err = os.WriteFile(path, data, 0644); err != nil {
55 | log.Fatalf("error: %v", err)
56 | }
57 | fmt.Printf("Wrote config file created at %s\n", path)
58 | fmt.Println("Note: You must add 'http://localhost:4200' to your Neynar app's Authorzed origins to sign in!!")
59 | fmt.Println("\nYou can now run `tofui` to start the app")
60 |
61 | },
62 | }
63 |
64 | func init() {
65 | rootCmd.AddCommand(initCmd)
66 |
67 | }
68 |
--------------------------------------------------------------------------------
/ui/replies.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "log"
5 |
6 | tea "github.com/charmbracelet/bubbletea"
7 |
8 | "github.com/treethought/tofui/api"
9 | )
10 |
11 | type repliesMsg struct {
12 | castConvo *api.Cast
13 | err error
14 | }
15 |
16 | type RepliesView struct {
17 | app *App
18 | opHash string
19 | convo *api.Cast
20 | items []*CastFeedItem
21 | feed *FeedView
22 | }
23 |
24 | func getConvoCmd(client *api.Client, signer *api.Signer, hash string) tea.Cmd {
25 | return func() tea.Msg {
26 | cc, err := client.GetCastWithReplies(signer, hash)
27 | if err != nil {
28 | return &repliesMsg{err: err}
29 | }
30 | return &repliesMsg{castConvo: cc}
31 | }
32 | }
33 |
34 | func NewRepliesView(app *App) *RepliesView {
35 | feed := NewFeedView(app, feedTypeReplies)
36 | feed.SetShowChannel(false)
37 | feed.SetShowStats(false)
38 | return &RepliesView{
39 | feed: feed,
40 | app: app,
41 | }
42 | }
43 |
44 | func (m *RepliesView) Init() tea.Cmd {
45 | return nil
46 | }
47 |
48 | func (m *RepliesView) Clear() {
49 | m.feed.Clear()
50 | m.opHash = ""
51 | m.convo = nil
52 | m.items = nil
53 | }
54 |
55 | func (m *RepliesView) SetOpHash(hash string) tea.Cmd {
56 | m.Clear()
57 | m.opHash = hash
58 | if m.app == nil {
59 | log.Println("app is nil")
60 | }
61 | if m.app.ctx == nil {
62 | log.Println("app context is nil")
63 | }
64 | if m.app.ctx.signer == nil {
65 | log.Println("signer is nil")
66 | }
67 |
68 | return getConvoCmd(m.app.client, m.app.ctx.signer, hash)
69 | }
70 |
71 | func (m *RepliesView) SetSize(w, h int) {
72 | m.feed.SetSize(w, h)
73 | }
74 |
75 | func (m *RepliesView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
76 | switch msg := msg.(type) {
77 | case *repliesMsg:
78 | if msg.err != nil {
79 | log.Println("error getting convo: ", msg.err)
80 | return m, nil
81 | }
82 | m.Clear()
83 | m.convo = msg.castConvo
84 | return m, m.feed.setItems(msg.castConvo.DirectReplies)
85 | }
86 | _, cmd := m.feed.Update(msg)
87 | return m, cmd
88 | }
89 |
90 | func (m *RepliesView) View() string {
91 | return m.feed.View()
92 | }
93 |
--------------------------------------------------------------------------------
/api/user.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "log"
8 |
9 | "github.com/treethought/tofui/db"
10 | )
11 |
12 | type Profile struct {
13 | Bio struct {
14 | Text string
15 | }
16 | }
17 |
18 | type VerifiedAddresses struct {
19 | EthAddresses []string `json:"eth_addresses"`
20 | SolAddresses []string `json:"sol_addresses"`
21 | }
22 |
23 | type ViewerContext struct {
24 | Following bool `json:"following"`
25 | FollowedBy bool `json:"followed_by"`
26 | }
27 |
28 | type BulkUsersResponse struct {
29 | Users []*User `json:"users"`
30 | }
31 |
32 | type User struct {
33 | FID uint64 `json:"fid"`
34 | Username string `json:"username"`
35 | DisplayName string `json:"display_name"`
36 | PfpURL string `json:"pfp_url"`
37 | Profile Profile `json:"profile"`
38 | FollowerCount int32 `json:"follower_count"`
39 | FollowingCount int32 `json:"following_count"`
40 | Verifications []string `json:"verifications"`
41 | VerifiedAddresses VerifiedAddresses `json:"verified_addresses"`
42 | ActiveStatus string `json:"active_status"`
43 | PowerBadge bool `json:"power_badge"`
44 | ViewerContext ViewerContext `json:"viewer_context"`
45 | }
46 |
47 | func (c *Client) GetUserByFID(fid uint64, viewer uint64) (*User, error) {
48 | key := fmt.Sprintf("user:%d", fid)
49 | cached, err := db.GetDB().Get([]byte(key))
50 | if err == nil {
51 | u := &User{}
52 | if err := json.Unmarshal(cached, u); err != nil {
53 | log.Fatal("failed to unmarshal cached user: ", err)
54 | }
55 | log.Println("got cached user: ", u.Username)
56 | return u, nil
57 | }
58 |
59 | path := "/user/bulk"
60 |
61 | opts := []RequestOption{
62 | WithQuery("fids", fmt.Sprintf("%d", fid)),
63 | }
64 | if viewer != 0 {
65 | opts = append(opts, WithQuery("viewer_fid", fmt.Sprintf("%d", viewer)))
66 | }
67 |
68 | var resp BulkUsersResponse
69 | if err := c.doRequestInto(context.TODO(), path, &resp, opts...); err != nil {
70 | return nil, err
71 | }
72 | if len(resp.Users) == 0 {
73 | return nil, fmt.Errorf("user not found")
74 | }
75 | user := resp.Users[0]
76 | d, _ := json.Marshal(user)
77 | if err := db.GetDB().Set([]byte(key), []byte(d)); err != nil {
78 | log.Println("failed to cache user: ", err)
79 | }
80 | return user, nil
81 | }
82 |
--------------------------------------------------------------------------------
/cmd/root.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "log"
7 | "os"
8 | "path/filepath"
9 |
10 | tea "github.com/charmbracelet/bubbletea"
11 | "github.com/spf13/cobra"
12 |
13 | "github.com/treethought/tofui/config"
14 | "github.com/treethought/tofui/db"
15 | "github.com/treethought/tofui/ui"
16 | )
17 |
18 | var (
19 | configPath = os.Getenv("CONFIG_FILE")
20 | cfg *config.Config
21 | logFile *os.File
22 | )
23 |
24 | var rootCmd = &cobra.Command{
25 | Use: "tofui",
26 | Short: "terminally on farcaster user interface",
27 | Run: func(cmd *cobra.Command, args []string) {
28 | runLocal()
29 |
30 | },
31 | }
32 |
33 | func runLocal() {
34 | defer logFile.Close()
35 | defer db.GetDB().Close()
36 | sv := &Server{
37 | prgmSessions: make(map[string][]*tea.Program),
38 | }
39 | go sv.startSigninHTTPServer()
40 | app := ui.NewLocalApp(cfg, false)
41 | p := tea.NewProgram(app, tea.WithAltScreen())
42 | sv.prgmSessions["local"] = append(sv.prgmSessions["local"], p)
43 | if _, err := p.Run(); err != nil {
44 | fmt.Printf("Alas, there's been an error: %v", err)
45 | os.Exit(1)
46 | }
47 | }
48 |
49 | func Execute() {
50 | err := rootCmd.Execute()
51 | if err != nil {
52 | os.Exit(1)
53 | }
54 | }
55 |
56 | func init() {
57 | cobra.OnInitialize(initConfig)
58 | rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "config file (default is $HOME/.tofui.yaml)")
59 | }
60 |
61 | func initConfig() {
62 | if len(os.Args) > 1 && os.Args[1] == "init" {
63 | return
64 | }
65 | var err error
66 | if configPath == "" {
67 | if _, err := os.Stat("config.yaml"); err == nil {
68 | configPath = "config.yaml"
69 | } else {
70 | homeDir, err := os.UserHomeDir()
71 | if err != nil {
72 | log.Fatal("failed to find default config file: ", err)
73 | }
74 | configPath = filepath.Join(homeDir, ".tofui", "config.yaml")
75 | }
76 | }
77 | cfg, err = config.ReadConfig(configPath)
78 | if err != nil {
79 | if errors.Is(err, os.ErrNotExist) {
80 | log.Fatal("failed to fing config file, run `tofui init` to create one")
81 | }
82 | log.Fatal("failed to read config: ", err)
83 | }
84 |
85 | lf := cfg.Log.Path
86 | if lf == "" {
87 | lf = "tofui.log"
88 | }
89 | dir := filepath.Dir(lf)
90 | err = os.MkdirAll(dir, 0755)
91 | if err != nil {
92 | log.Fatal(err)
93 | }
94 | logFile, err = tea.LogToFile(lf, "debug")
95 | if err != nil {
96 | log.Fatal(err)
97 | os.Exit(1)
98 | }
99 | log.Println("loaded config: ", configPath)
100 | db.InitDB(cfg)
101 |
102 | }
103 |
--------------------------------------------------------------------------------
/db/db.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | slog "log"
5 | "os"
6 | "sync"
7 | "time"
8 |
9 | badger "github.com/dgraph-io/badger/v4"
10 | log "github.com/sirupsen/logrus"
11 |
12 | "github.com/treethought/tofui/config"
13 | )
14 |
15 | var (
16 | db *DB
17 | once sync.Once
18 | )
19 |
20 | func GetDB() *DB {
21 | return db
22 | }
23 |
24 | type DB struct {
25 | db *badger.DB
26 | lf *os.File
27 | }
28 |
29 | func InitDB(cfg *config.Config) {
30 | once.Do(func() {
31 | path := cfg.DB.Dir
32 | if path == "" {
33 | path = ".tofui/db"
34 | }
35 |
36 | err := os.MkdirAll(path, 0755)
37 | if err != nil {
38 | log.Fatalf("failed to create db directory: %v", err)
39 | }
40 |
41 | lfPath := path + "/db.log"
42 |
43 | lf, err := os.Create(lfPath)
44 | if err != nil {
45 | log.Fatalf("failed to create db log file: %v", err)
46 | }
47 | slog.Print("opening db:", path)
48 |
49 | logger := log.New()
50 | logger.SetOutput(lf)
51 | opts := badger.DefaultOptions(path)
52 | opts.Logger = logger
53 |
54 | b, err := badger.Open(opts)
55 | if err != nil {
56 | log.Fatal("failed to open db: ", err)
57 | }
58 | d := &DB{db: b, lf: lf}
59 | db = d
60 | go db.runGC()
61 | })
62 | }
63 |
64 | func (db *DB) runGC() {
65 | ticker := time.NewTicker(5 * time.Minute)
66 | defer ticker.Stop()
67 |
68 | for range ticker.C {
69 | again:
70 | err := db.db.RunValueLogGC(0.7)
71 | if err == nil {
72 | goto again
73 | }
74 | }
75 | }
76 |
77 | func (db *DB) Close() {
78 | slog.Println("closing db")
79 | if db != nil && db.db != nil {
80 | db.db.Close()
81 | db.lf.Close()
82 | }
83 | }
84 |
85 | func (db *DB) Set(key, value []byte) error {
86 | return db.db.Update(func(txn *badger.Txn) error {
87 | return txn.Set(key, value)
88 | })
89 | }
90 |
91 | func (db *DB) Get(key []byte) ([]byte, error) {
92 | var value []byte
93 | err := db.db.View(func(txn *badger.Txn) error {
94 | item, err := txn.Get(key)
95 | if err != nil {
96 | return err
97 | }
98 | value, err = item.ValueCopy(nil)
99 | return err
100 | })
101 | return value, err
102 | }
103 |
104 | func (db *DB) Delete(key []byte) error {
105 | return db.db.Update(func(txn *badger.Txn) error {
106 | return txn.Delete(key)
107 | })
108 | }
109 |
110 | func (db *DB) GetKeys(prefix []byte) ([][]byte, error) {
111 | keys := make([][]byte, 0)
112 | err := db.db.View(func(txn *badger.Txn) error {
113 | it := txn.NewIterator(badger.DefaultIteratorOptions)
114 | defer it.Close()
115 | for it.Seek(prefix); it.ValidForPrefix(prefix); it.Next() {
116 | item := it.Item()
117 | k := item.KeyCopy(nil)
118 | keys = append(keys, k)
119 | }
120 | return nil
121 | })
122 | return keys, err
123 | }
124 |
--------------------------------------------------------------------------------
/ui/splash.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/charmbracelet/bubbles/spinner"
7 | "github.com/charmbracelet/bubbles/viewport"
8 | tea "github.com/charmbracelet/bubbletea"
9 | "github.com/charmbracelet/lipgloss"
10 | )
11 |
12 | var txt = `
13 | ████████╗ ██████╗ ███████╗██╗ ██╗██╗
14 | ╚══██╔══╝██╔═══██╗██╔════╝██║ ██║██║
15 | ██║ ██║ ██║█████╗ ██║ ██║██║
16 | ██║ ██║ ██║██╔══╝ ██║ ██║██║
17 | ██║ ╚██████╔╝██║ ╚██████╔╝██║
18 | ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝
19 |
20 | Terminally On Farcaster User Interface
21 | `
22 |
23 | var splashStyle = NewStyle().Align(lipgloss.Center).Margin(2, 2)
24 |
25 | type SplashView struct {
26 | app *App
27 | vp *viewport.Model
28 | info *viewport.Model
29 | loading *Loading
30 | active bool
31 | signin bool
32 | }
33 |
34 | func NewSplashView(app *App) *SplashView {
35 | x, y := lipgloss.Size(txt)
36 | vp := viewport.New(x, y)
37 | vp.SetContent(txt)
38 | l := NewLoading()
39 | l.SetActive(true)
40 | info := viewport.New(20, 6)
41 | info.SetContent("fetching feed...")
42 | s := spinner.New()
43 | s.Spinner = spinner.Dot
44 | s.Style = NewStyle().Foreground(lipgloss.Color("205"))
45 | return &SplashView{
46 | vp: &vp, loading: l,
47 | info: &info, active: true,
48 | app: app,
49 | }
50 | }
51 |
52 | func (m *SplashView) Active() bool {
53 | return m.active
54 | }
55 | func (m *SplashView) SetActive(active bool) {
56 | m.loading.SetActive(active)
57 | m.active = active
58 | }
59 | func (m *SplashView) ShowSignin(v bool) {
60 | m.loading.SetActive(!v)
61 | m.signin = v
62 | if v {
63 | m.info.SetContent("Press Enter to sign in")
64 | }
65 | }
66 | func (m *SplashView) SetInfo(content string) {
67 | if m.signin {
68 | return
69 | }
70 | m.info.SetContent(content)
71 | }
72 |
73 | func (m *SplashView) SetSize(w, h int) {
74 | x, y := splashStyle.GetFrameSize()
75 | m.vp.Width = w - x
76 | m.vp.Height = h - y - 4
77 | m.info.Width = w - x
78 | m.info.Height = h - y - 8
79 | m.loading.SetSize((w-x)/2, h)
80 | }
81 |
82 | func (m *SplashView) Init() tea.Cmd {
83 | return tea.Batch(m.loading.Init())
84 | }
85 | func (m *SplashView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
86 | if m.signin {
87 | switch msg := msg.(type) {
88 | case tea.KeyMsg:
89 | if msg.String() == "enter" {
90 | portPart := fmt.Sprintf(":%d", m.app.cfg.Server.HTTPPort)
91 | if portPart == ":443" {
92 | portPart = ""
93 | }
94 | u := fmt.Sprintf("%s/signin?pk=%s", m.app.cfg.BaseURL(), m.app.ctx.pk)
95 | m.info.SetContent(fmt.Sprintf("Please sign in at %s", u))
96 | return m, OpenURL(u)
97 | }
98 | }
99 | }
100 |
101 | if !m.active {
102 | return m, nil
103 | }
104 | _, cmd := m.loading.Update(msg)
105 | return m, cmd
106 | }
107 | func (m *SplashView) View() string {
108 | return splashStyle.Render(
109 | lipgloss.JoinVertical(lipgloss.Top,
110 | m.vp.View(),
111 | lipgloss.NewStyle().MarginTop(1).Render(m.loading.View()),
112 | m.info.View(),
113 | ),
114 | )
115 | }
116 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # tofui
2 |
3 | tofui (Terminally On Farcaster User Interface) is a TUI for
4 | [farcaster](https://www.farcaster.xyz/).
5 |
6 | It supports running locally using your own [Neynar](https://neynar.com/)
7 | application, or as a hosted SSH app using
8 | [wish](https://github.com/charmbracelet/wish).
9 |
10 | 
11 |
12 | ## Running Locally
13 |
14 |
15 | Running locally requires your own Neynar application. After creating one, run the following to create your config file
16 |
17 | ```
18 | tofui init
19 | ```
20 |
21 | Starting tofui the first time will then give you the option to sign in
22 |
23 | ### Install
24 |
25 | Install using go
26 |
27 | ```
28 | go install github.com/treethought/tofui@latest
29 | ```
30 |
31 | Or clone the repo and run
32 |
33 | ```
34 | make build
35 | ```
36 |
37 | Or download a binary from the
38 | [releases](https://github.com/treethought/tofui/releases) page
39 |
40 | Then start the TUI via `tofui`
41 |
42 | ## Keybindings
43 |
44 | #### Navigation
45 |
46 | | Key | Action |
47 | | --------- | ------------------------------------------- |
48 | | Tab | Toggle focus between sidebar and main panel |
49 | | Shift-Tab | Toggle sidebar visibility |
50 | | K / Up | Move up in list |
51 | | J / Down | Move down in list |
52 | | Escape | Go to previous view |
53 | | Enter | Select current item |
54 | | F
| Jump to your feed |
55 | | Ctrl-K | Open channel quick switcher
|
56 | | ? | Open help |
57 | | c | View channel of current item |
58 | | p | View profile of current item |
59 |
60 | #### Actions
61 |
62 | | Key | Action |
63 | | ------ | ---------------------------------------------- |
64 | | ctrl-d | Submit cast/reply in publish view |
65 | | P | Open publish form |
66 | | C | Open reply form when viewing cast |
67 | | o | Open current cast in browser (local mode only) |
68 | | l | Like current cast |
69 |
70 | ## Hosted version (WIP and often unavailable)
71 |
72 | Use a hosted instance of tofui over ssh. (Note: this is WIP and currently unavailable)
73 |
74 | ```
75 | ssh -p 42069 tofui.xyz
76 | ```
77 |
78 | ### SSH Sessions, Authentication and Details
79 |
80 | Each SSH session is authenticated via it's SSH public key. The session then
81 | receives it's own [Bubble Tea](https://github.com/charmbracelet/bubbletea) which
82 | provides the interface.
83 |
84 | For authorization, the app directs you to create a signer via Neynar's
85 | [SIWN](https://docs.neynar.com/docs/how-to-let-users-connect-farcaster-accounts-with-write-access-for-free-using-sign-in-with-neynar-siwn).
86 | This signer is created and managed by Neynar, and is used to provide tofui
87 | access to your farcaster account via it's API.
88 |
89 | This is done when both running locally and over SSH, and the signer is specific
90 | to whichever app credentials were used. This would be tofui over SSH, or your
91 | own app when running locally.
92 |
93 |
--------------------------------------------------------------------------------
/api/cast.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "log"
8 | "time"
9 | )
10 |
11 | type Embed struct {
12 | URL string `json:"url"`
13 | CastId struct {
14 | Hash string `json:"hash"`
15 | FID int32 `json:"fid"`
16 | }
17 | }
18 |
19 | type Reaction struct {
20 | FID int32 `json:"fid"`
21 | FName string `json:"fname"`
22 | }
23 |
24 | type Reactions struct {
25 | LikesCount uint `json:"likes_count"`
26 | RecastsCount uint `json:"recasts_count"`
27 | Likes []Reaction `json:"likes"`
28 | Recasts []Reaction `json:"recasts"`
29 | }
30 |
31 | type Cast struct {
32 | Object string `json:"object"`
33 | Hash string `json:"hash"`
34 | ThreadHash string `json:"thread_hash"`
35 | ParentHash string `json:"parent_hash"`
36 | ParentURL string `json:"parent_url"`
37 | ParentAuthor struct {
38 | FID int32
39 | } `json:"parent_author"`
40 | Author User `json:"author"`
41 | Text string `json:"text"`
42 | Timestamp time.Time `json:"timestamp"`
43 | Embeds []Embed `json:"embeds"`
44 | Reactions Reactions `json:"reactions"`
45 | Replies struct {
46 | Count int32 `json:"count"`
47 | }
48 | DirectReplies []*Cast `json:"direct_replies"`
49 | ViewerContext struct {
50 | Liked bool `json:"liked"`
51 | Recasted bool `json:"recasted"`
52 | } `json:"viewer_context"`
53 | }
54 |
55 | func (c Cast) HumanTime() string {
56 | return c.Timestamp.Format("Jan 2 15:04")
57 | }
58 |
59 | type CastClient struct {
60 | c *Client
61 | }
62 |
63 | type CastPayload struct {
64 | SignerUUID string `json:"signer_uuid"`
65 | Text string `json:"text"`
66 | Parent string `json:"parent"`
67 | ChannelID string `json:"channel_id"`
68 | Idem string `json:"idem"`
69 | ParentAuthorFID uint64 `json:"parent_author_fid"`
70 | Embeds []Embed `json:"embeds"`
71 | }
72 |
73 | type PostCastResponse struct {
74 | Success bool
75 | Cast Cast
76 | }
77 |
78 | func (c *Client) PostCast(signer *Signer, text, parent, channel string, parent_fid uint64) (*PostCastResponse, error) {
79 | if signer == nil {
80 | return nil, errors.New("signer required")
81 | }
82 | payload := CastPayload{
83 | Text: text,
84 | SignerUUID: signer.UUID,
85 | Parent: parent,
86 | ChannelID: channel,
87 | ParentAuthorFID: parent_fid,
88 | }
89 | log.Println("posting cast: ", text)
90 |
91 | var resp PostCastResponse
92 | if err := c.doPostInto(context.TODO(), "/cast", payload, &resp); err != nil {
93 | log.Println("failed to post cast: ", err)
94 | return nil, err
95 | }
96 | if !resp.Success {
97 | return nil, errors.New("failed to post cast")
98 | }
99 |
100 | return &resp, nil
101 | }
102 |
103 | type ConversationResponse struct {
104 | Conversation *struct {
105 | Cast Cast `json:"cast"`
106 | } `json:"conversation"`
107 | }
108 |
109 | type Conversation struct {
110 | Cast
111 | }
112 |
113 | func (c *Client) GetCastWithReplies(signer *Signer, hash string) (*Cast, error) {
114 | path := "/cast/conversation"
115 | opts := []RequestOption{
116 | WithQuery("identifier", hash),
117 | WithQuery("type", "hash"),
118 | WithQuery("reply_depth", "10"),
119 | }
120 | if signer != nil {
121 | opts = append(opts, WithQuery("viewer_fid", fmt.Sprintf("%d", signer.FID)))
122 | }
123 |
124 | var resp ConversationResponse
125 | if err := c.doRequestInto(context.TODO(), path, &resp, opts...); err != nil {
126 | return nil, err
127 | }
128 | if resp.Conversation == nil {
129 | return nil, errors.New("no replies found")
130 | }
131 | return &resp.Conversation.Cast, nil
132 | }
133 |
--------------------------------------------------------------------------------
/ui/profile.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | "log"
6 |
7 | tea "github.com/charmbracelet/bubbletea"
8 | "github.com/charmbracelet/lipgloss"
9 |
10 | "github.com/treethought/tofui/api"
11 | )
12 |
13 | func UserBio(user *api.User) string {
14 | if user == nil {
15 | l := NewLoading()
16 | l.SetActive(true)
17 | return l.View()
18 | }
19 | stats := lipgloss.JoinHorizontal(lipgloss.Top,
20 | NewStyle().Bold(true).Render(fmt.Sprintf("%d", user.FollowingCount)),
21 | NewStyle().MarginRight(10).Render(" following"),
22 | NewStyle().Bold(true).Render(fmt.Sprintf("%d", user.FollowerCount)),
23 | NewStyle().Render(" followers"),
24 | )
25 |
26 | style := NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderBottom(true).Padding(2)
27 |
28 | return style.Render(lipgloss.JoinVertical(lipgloss.Top,
29 | NewStyle().MarginTop(0).MarginBottom(0).Padding(0).Render(user.Profile.Bio.Text),
30 | stats,
31 | ))
32 |
33 | }
34 |
35 | type profileFeedMsg struct {
36 | fid uint64
37 | casts []*api.Cast
38 | }
39 |
40 | type SelectProfileMsg struct {
41 | fid uint64
42 | }
43 |
44 | type ProfileMsg struct {
45 | fid uint64
46 | user *api.User
47 | err error
48 | }
49 |
50 | type Profile struct {
51 | app *App
52 | user *api.User
53 | pfp *ImageModel
54 | feed *FeedView
55 | }
56 |
57 | func NewProfile(app *App) *Profile {
58 | f := NewFeedView(app, feedTypeProfile)
59 | return &Profile{
60 | app: app,
61 | pfp: NewImage(false, true, special),
62 | feed: f,
63 | }
64 | }
65 |
66 | func getUserCmd(client *api.Client, fid, viewer uint64) tea.Cmd {
67 | return func() tea.Msg {
68 | log.Println("get user by fid cmd", fid)
69 | user, err := client.GetUserByFID(fid, viewer)
70 | return ProfileMsg{fid, user, err}
71 | }
72 | }
73 |
74 | func getUserFeedCmd(client *api.Client, fid, viewer uint64) tea.Cmd {
75 | return func() tea.Msg {
76 | req := &api.FeedRequest{
77 | FeedType: "filter", FilterType: "fids", Limit: 100,
78 | FIDs: []uint64{fid}, ViewerFID: viewer, FID: viewer,
79 | }
80 | feed, err := client.GetFeed(req)
81 | if err != nil {
82 | log.Println("feedview error getting feed", err)
83 | return err
84 | }
85 | return &profileFeedMsg{fid, feed.Casts}
86 | }
87 | }
88 |
89 | func (m *Profile) SetFID(fid uint64) tea.Cmd {
90 | var viewer uint64
91 | if m.app.ctx.signer != nil {
92 | viewer = m.app.ctx.signer.FID
93 | }
94 | return tea.Batch(
95 | getUserCmd(m.app.client, fid, viewer),
96 | getUserFeedCmd(m.app.client, fid, viewer),
97 | )
98 | }
99 |
100 | func (m *Profile) Init() tea.Cmd {
101 | return m.feed.Init()
102 | }
103 |
104 | func (m *Profile) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
105 | switch msg := msg.(type) {
106 | case tea.WindowSizeMsg:
107 | x, y := msg.Width, msg.Height
108 | m.pfp.SetSize(4, 4)
109 |
110 | hy := lipgloss.Height(UsernameHeader(m.user, m.pfp))
111 | by := lipgloss.Height(UserBio(m.user))
112 |
113 | fy := y - hy - by
114 |
115 | // TODO use size of header/stats
116 | m.feed.SetSize(x, fy)
117 | return m, nil
118 |
119 | case *SelectProfileMsg:
120 | return m, m.SetFID(msg.fid)
121 |
122 | case ProfileMsg:
123 | if msg.user != nil {
124 | m.user = msg.user
125 | m.pfp.SetURL(m.user.PfpURL, false)
126 | m.pfp.SetSize(4, 4)
127 | return m, tea.Batch(
128 | m.pfp.Render(),
129 | navNameCmd(fmt.Sprintf("profile: @%s", m.user.Username)),
130 | )
131 | }
132 | return m, nil
133 | }
134 | _, fcmd := m.feed.Update(msg)
135 | _, pcmd := m.pfp.Update(msg)
136 | return m, tea.Batch(fcmd, pcmd)
137 | }
138 | func (m *Profile) View() string {
139 | return lipgloss.JoinVertical(lipgloss.Center,
140 | UsernameHeader(m.user, m.pfp),
141 | UserBio(m.user),
142 | m.feed.View(),
143 | )
144 | }
145 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/treethought/tofui
2 |
3 | go 1.21
4 |
5 | toolchain go1.22.3
6 |
7 | require (
8 | github.com/PuerkitoBio/goquery v1.9.2
9 | github.com/charmbracelet/bubbles v0.16.1
10 | github.com/charmbracelet/bubbletea v0.26.4
11 | github.com/charmbracelet/glamour v0.7.0
12 | github.com/charmbracelet/lipgloss v0.11.0
13 | github.com/charmbracelet/log v0.4.0
14 | github.com/charmbracelet/ssh v0.0.0-20240401141849-854cddfa2917
15 | github.com/charmbracelet/wish v1.4.0
16 | github.com/dgraph-io/badger/v4 v4.2.0
17 | github.com/disintegration/imaging v1.6.2
18 | github.com/lucasb-eyer/go-colorful v1.2.0
19 | github.com/mistakenelf/teacup v0.4.1
20 | github.com/muesli/termenv v0.15.2
21 | github.com/sirupsen/logrus v1.9.3
22 | github.com/spf13/cobra v1.8.0
23 | golang.org/x/image v0.16.0
24 | gopkg.in/yaml.v3 v3.0.1
25 | )
26 |
27 | require (
28 | github.com/alecthomas/chroma/v2 v2.14.0 // indirect
29 | github.com/andybalholm/cascadia v1.3.2 // indirect
30 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
31 | github.com/atotto/clipboard v0.1.4 // indirect
32 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
33 | github.com/aymerick/douceur v0.2.0 // indirect
34 | github.com/cespare/xxhash/v2 v2.3.0 // indirect
35 | github.com/charmbracelet/harmonica v0.2.0 // indirect
36 | github.com/charmbracelet/keygen v0.5.0 // indirect
37 | github.com/charmbracelet/x/ansi v0.1.2 // indirect
38 | github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651 // indirect
39 | github.com/charmbracelet/x/exp/term v0.0.0-20240328150354-ab9afc214dfd // indirect
40 | github.com/charmbracelet/x/input v0.1.1 // indirect
41 | github.com/charmbracelet/x/term v0.1.1 // indirect
42 | github.com/charmbracelet/x/windows v0.1.2 // indirect
43 | github.com/creack/pty v1.1.21 // indirect
44 | github.com/dgraph-io/ristretto v0.1.1 // indirect
45 | github.com/dlclark/regexp2 v1.11.0 // indirect
46 | github.com/dustin/go-humanize v1.0.1 // indirect
47 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
48 | github.com/go-logfmt/logfmt v0.6.0 // indirect
49 | github.com/gogo/protobuf v1.3.2 // indirect
50 | github.com/golang/glog v1.2.1 // indirect
51 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
52 | github.com/golang/protobuf v1.5.4 // indirect
53 | github.com/golang/snappy v0.0.4 // indirect
54 | github.com/google/flatbuffers v24.3.25+incompatible // indirect
55 | github.com/gorilla/css v1.0.1 // indirect
56 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
57 | github.com/klauspost/compress v1.17.8 // indirect
58 | github.com/kr/text v0.2.0 // indirect
59 | github.com/mattn/go-isatty v0.0.20 // indirect
60 | github.com/mattn/go-localereader v0.0.1 // indirect
61 | github.com/mattn/go-runewidth v0.0.15 // indirect
62 | github.com/microcosm-cc/bluemonday v1.0.26 // indirect
63 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
64 | github.com/muesli/cancelreader v0.2.2 // indirect
65 | github.com/muesli/reflow v0.3.0 // indirect
66 | github.com/olekukonko/tablewriter v0.0.5 // indirect
67 | github.com/pkg/errors v0.9.1 // indirect
68 | github.com/rivo/uniseg v0.4.7 // indirect
69 | github.com/sahilm/fuzzy v0.1.1 // indirect
70 | github.com/spf13/pflag v1.0.5 // indirect
71 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
72 | github.com/yuin/goldmark v1.7.1 // indirect
73 | github.com/yuin/goldmark-emoji v1.0.2 // indirect
74 | go.opencensus.io v0.24.0 // indirect
75 | golang.org/x/crypto v0.23.0 // indirect
76 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
77 | golang.org/x/net v0.25.0 // indirect
78 | golang.org/x/sync v0.7.0 // indirect
79 | golang.org/x/sys v0.20.0 // indirect
80 | golang.org/x/text v0.15.0 // indirect
81 | google.golang.org/protobuf v1.34.1 // indirect
82 | )
83 |
--------------------------------------------------------------------------------
/api/client.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "io"
9 | "log"
10 | "net/http"
11 | "sync"
12 |
13 | "github.com/treethought/tofui/config"
14 | )
15 |
16 | var (
17 | client *Client
18 | clientOnce sync.Once
19 | )
20 |
21 | type NeynarError struct {
22 | message string
23 | status int
24 | path string
25 | error error
26 | }
27 |
28 | func (e NeynarError) Error() string {
29 | if e.error != nil {
30 | return fmt.Sprintf("%s: %s", e.path, e.error)
31 | }
32 | if e.status != 0 {
33 | return fmt.Sprintf("%s %d: %s", e.path, e.status, e.message)
34 | }
35 | return fmt.Sprintf("%s: %s", e.path, e.message)
36 | }
37 |
38 | type Client struct {
39 | c *http.Client
40 | apiKey string
41 | baseURL string
42 | clientID string
43 | persistantOpts []RequestOption
44 | }
45 |
46 | func NewClient(cfg *config.Config) *Client {
47 | clientOnce.Do(func() {
48 | client = &Client{
49 | c: http.DefaultClient,
50 | apiKey: cfg.Neynar.APIKey,
51 | baseURL: cfg.Neynar.BaseUrl,
52 | clientID: cfg.Neynar.ClientID,
53 | }
54 | })
55 | return client
56 | }
57 |
58 | func (c *Client) SetOptions(opts ...RequestOption) {
59 | c.persistantOpts = opts
60 | }
61 |
62 | func (c *Client) buildEndpoint(path string) string {
63 | return c.baseURL + path
64 | }
65 |
66 | func (c *Client) SetAPIKey(key string) {
67 | c.apiKey = key
68 | }
69 |
70 | func (c *Client) doPostRequest(ctx context.Context, path string, body io.Reader, opts ...RequestOption) (*http.Response, error) {
71 | url := c.buildEndpoint(path)
72 |
73 | log.Println("sending request to: ", url)
74 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
75 | if err != nil {
76 | log.Println("failed to create request: ", err)
77 | return nil, err
78 | }
79 | req.Header.Add("accept", "application/json")
80 | req.Header.Add("api_key", c.apiKey)
81 | req.Header.Add("content-type", "application/json")
82 |
83 | for _, opt := range c.persistantOpts {
84 | log.Println("applying persistant option")
85 | opt(req)
86 | }
87 |
88 | for _, opt := range opts {
89 | opt(req)
90 | }
91 | return c.c.Do(req)
92 | }
93 |
94 | func (c *Client) doRequest(ctx context.Context, path string, opts ...RequestOption) (*http.Response, error) {
95 | url := c.buildEndpoint(path)
96 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
97 | if err != nil {
98 | return nil, err
99 | }
100 | req.Header.Add("accept", "application/json")
101 | req.Header.Add("api_key", c.apiKey)
102 |
103 | for _, opt := range c.persistantOpts {
104 | opt(req)
105 | }
106 |
107 | for _, opt := range opts {
108 | opt(req)
109 | }
110 | return c.c.Do(req)
111 | }
112 |
113 | func (c *Client) doRequestInto(ctx context.Context, path string, v interface{}, opts ...RequestOption) error {
114 | res, err := c.doRequest(ctx, path, opts...)
115 | if err != nil {
116 | return NeynarError{"failed to create request", 0, path, err}
117 | }
118 | defer res.Body.Close()
119 |
120 | if res.StatusCode != http.StatusOK {
121 | d, _ := io.ReadAll(res.Body)
122 | return NeynarError{string(d), res.StatusCode, path, nil}
123 | }
124 |
125 | if err := json.NewDecoder(res.Body).Decode(v); err != nil {
126 | return NeynarError{"failed to decode response", res.StatusCode, path, err}
127 | }
128 | return nil
129 | }
130 |
131 | func (c *Client) doPostInto(ctx context.Context, path string, body interface{}, v interface{}, opts ...RequestOption) error {
132 | data, err := json.Marshal(body)
133 | if err != nil {
134 | return NeynarError{"failed to marshal body", 0, path, err}
135 | }
136 | log.Println("sending payload: ", string(data))
137 |
138 | r := bytes.NewReader(data)
139 | resp, err := c.doPostRequest(ctx, path, r, opts...)
140 | if err != nil {
141 | return NeynarError{"failed to create request", 0, path, err}
142 | }
143 | defer resp.Body.Close()
144 |
145 | if resp.StatusCode != http.StatusOK {
146 | d, _ := io.ReadAll(resp.Body)
147 | return NeynarError{string(d), resp.StatusCode, path, nil}
148 | }
149 |
150 | if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
151 | return NeynarError{"failed to decode response", resp.StatusCode, path, err}
152 | }
153 | return nil
154 | }
155 |
156 | type RequestOption func(*http.Request)
157 |
158 | func setQueryParam(r *http.Request, key, value string) {
159 | q := r.URL.Query()
160 | q.Add(key, value)
161 | r.URL.RawQuery = q.Encode()
162 | }
163 |
164 | func WithQuery(key, value string) RequestOption {
165 | return func(r *http.Request) {
166 | setQueryParam(r, key, value)
167 | }
168 | }
169 |
170 | func WithLimit(limit int) RequestOption {
171 | return WithQuery("limit", fmt.Sprintf("%d", limit))
172 | }
173 |
174 | func WithFID(fid uint64) RequestOption {
175 | return WithQuery("fid", fmt.Sprintf("%d", fid))
176 | }
177 |
--------------------------------------------------------------------------------
/ui/quickselect.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/charmbracelet/bubbles/list"
7 | tea "github.com/charmbracelet/bubbletea"
8 | "github.com/charmbracelet/lipgloss"
9 |
10 | "github.com/treethought/tofui/api"
11 | )
12 |
13 | type QuickSelect struct {
14 | app *App
15 | active bool
16 | channelList *list.Model
17 | w, h int
18 | onSelect func(i *selectItem) tea.Cmd
19 | }
20 |
21 | type selectItem struct {
22 | name string
23 | value string
24 | icon string
25 | itype string
26 | }
27 |
28 | func (m *selectItem) FilterValue() string {
29 | return m.name
30 | }
31 | func (m *selectItem) View() string {
32 | return m.value
33 | }
34 | func (m *selectItem) Title() string {
35 | return m.name
36 | }
37 |
38 | func (i *selectItem) Description() string {
39 | return ""
40 | }
41 |
42 | func NewQuickSelect(app *App) *QuickSelect {
43 | d := list.NewDefaultDelegate()
44 | d.SetHeight(1)
45 | d.ShowDescription = false
46 |
47 | l := list.New([]list.Item{}, d, 100, 100)
48 | l.KeyMap.CursorUp.SetKeys("k", "up")
49 | l.KeyMap.CursorDown.SetKeys("j", "down")
50 | l.KeyMap.Quit.SetKeys("ctrl+c")
51 | l.Title = "select channel"
52 | l.SetShowTitle(true)
53 | l.SetFilteringEnabled(true)
54 | l.SetShowFilter(true)
55 | l.SetShowHelp(true)
56 | l.SetShowStatusBar(true)
57 | l.SetShowPagination(true)
58 |
59 | return &QuickSelect{app: app, channelList: &l}
60 | }
61 |
62 | type channelListMsg struct {
63 | channels []*api.Channel
64 | activeOnly bool
65 | }
66 |
67 | func getUserChannels(client *api.Client, fid uint64, activeOnly bool) tea.Msg {
68 | channels, err := client.GetUserChannels(fid, activeOnly, api.WithLimit(100))
69 | if err != nil {
70 | log.Println("error getting user channels: ", err)
71 | return nil
72 | }
73 | return &channelListMsg{channels, activeOnly}
74 | }
75 |
76 | func getChannelsCmd(client *api.Client, activeOnly bool, fid uint64) tea.Cmd {
77 | return func() tea.Msg {
78 | if activeOnly && fid != 0 {
79 | return getUserChannels(client, fid, activeOnly)
80 | }
81 | msg := &channelListMsg{}
82 | ids, err := client.GetCachedChannelIds()
83 | if err != nil {
84 | log.Println("error getting channel names: ", err)
85 | }
86 | for _, id := range ids {
87 | channel, err := client.GetChannelById(id)
88 | if err != nil {
89 | log.Println("error getting channel: ", err)
90 | continue
91 | }
92 | msg.channels = append(msg.channels, channel)
93 | }
94 | return msg
95 | }
96 | }
97 | func (m *QuickSelect) SetOnSelect(f func(i *selectItem) tea.Cmd) {
98 | m.onSelect = f
99 | }
100 |
101 | func (m *QuickSelect) SetSize(w, h int) {
102 | m.w = w
103 | m.h = h
104 | m.channelList.SetSize(w, h)
105 | }
106 | func (m *QuickSelect) Active() bool {
107 | return m.active
108 | }
109 | func (m *QuickSelect) SetActive(active bool) {
110 | m.active = active
111 | }
112 |
113 | func (m *QuickSelect) Init() tea.Cmd {
114 | var fid uint64
115 | if m.app.ctx.signer != nil {
116 | fid = m.app.ctx.signer.FID
117 | }
118 | return tea.Batch(
119 | getChannelsCmd(m.app.client, false, fid), func() tea.Msg { return tea.KeyCtrlQuestionMark })
120 | }
121 |
122 | func (m *QuickSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
123 | switch msg := msg.(type) {
124 | case []*api.Channel:
125 | items := []list.Item{}
126 | for _, c := range msg {
127 | items = append(items, &selectItem{name: c.Name, value: c.ParentURL, itype: "channel"})
128 | }
129 | return m, m.channelList.SetItems(items)
130 |
131 | case tea.KeyMsg:
132 | if msg.String() == "ctrl+c" {
133 | return m, tea.Quit
134 | }
135 | if !m.active {
136 | return m, nil
137 | }
138 | if msg.String() == "enter" {
139 | if m.onSelect != nil {
140 | return m, m.onSelect(m.channelList.SelectedItem().(*selectItem))
141 | }
142 | currentItem, ok := m.channelList.SelectedItem().(*selectItem)
143 | if !ok {
144 | log.Println("no item selected")
145 | return m, nil
146 | }
147 | if currentItem.name == "profile" {
148 | log.Println("profile selected")
149 | if m.app.ctx.signer == nil {
150 | return m, nil
151 | }
152 | return m, tea.Sequence(
153 | selectProfileCmd(m.app.ctx.signer.FID),
154 | m.app.FocusProfile(),
155 | )
156 | }
157 | if currentItem.name == "feed" {
158 | log.Println("feed selected")
159 | return m, tea.Sequence(m.app.FocusFeed(), getDefaultFeedCmd(m.app.client, m.app.ctx.signer))
160 | }
161 | if currentItem.itype == "channel" {
162 | log.Println("channel selected")
163 | return m, tea.Sequence(
164 | m.app.FocusChannel(),
165 | getFeedCmd(m.app.client, &api.FeedRequest{
166 | FeedType: "filter", FilterType: "parent_url",
167 | ParentURL: currentItem.value, Limit: 100,
168 | }),
169 | )
170 | }
171 | }
172 | }
173 | l, cmd := m.channelList.Update(msg)
174 | m.channelList = &l
175 | return m, cmd
176 | }
177 |
178 | var dialogBoxStyle = NewStyle()
179 |
180 | func (m *QuickSelect) View() string {
181 | dialog := lipgloss.Place(m.h, m.h,
182 | lipgloss.Center, lipgloss.Center,
183 | dialogBoxStyle.Render(m.channelList.View()),
184 | lipgloss.WithWhitespaceChars("~~"),
185 | lipgloss.WithWhitespaceForeground(subtle),
186 | )
187 | return dialog
188 | }
189 |
--------------------------------------------------------------------------------
/ui/notifications.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | "log"
6 |
7 | "github.com/charmbracelet/bubbles/list"
8 | tea "github.com/charmbracelet/bubbletea"
9 |
10 | "github.com/treethought/tofui/api"
11 | )
12 |
13 | type notificationsMsg struct {
14 | notifications []*api.Notification
15 | }
16 |
17 | func getNotificationsCmd(client *api.Client, signer *api.Signer) tea.Cmd {
18 | return func() tea.Msg {
19 | if signer == nil {
20 | return nil
21 | }
22 | resp, err := client.GetNotifications(signer.FID)
23 | if err != nil {
24 | log.Println("error getting notifications: ", err)
25 | return nil
26 | }
27 | return ¬ificationsMsg{notifications: resp.Notifications}
28 | }
29 | }
30 |
31 | type notifItem struct {
32 | *api.Notification
33 | }
34 |
35 | func (n *notifItem) FilterValue() string {
36 | return string(n.Type)
37 | }
38 |
39 | func buildUserList(users []api.User) string {
40 | s := ""
41 | for i, u := range users {
42 | if i > 3 {
43 | s += fmt.Sprintf(" and %d others", len(users)-i)
44 | return s
45 | }
46 | s += u.DisplayName
47 | if i < len(users)-1 {
48 | s += ", "
49 | }
50 | }
51 | return s
52 | }
53 |
54 | func (n *notifItem) Title() string {
55 | switch n.Type {
56 | case api.NotificationsTypeFollows:
57 | users := []api.User{}
58 | for _, f := range n.Follows {
59 | users = append(users, f.User)
60 | }
61 | userStr := buildUserList(users)
62 | return fmt.Sprintf("%s %s followed you ", EmojiPerson, userStr)
63 | case api.NotificationsTypeLikes:
64 | users := []api.User{}
65 | for _, r := range n.Reactions {
66 | if r.Object == api.CastReactionObjTypeLikes {
67 | users = append(users, r.User)
68 | }
69 | }
70 | userStr := buildUserList(users)
71 | return fmt.Sprintf("%s %s liked your post", EmojiLike, userStr)
72 | case api.NotificationsTypeRecasts:
73 | users := []api.User{}
74 | for _, r := range n.Reactions {
75 | if r.Object == api.CastReactionObjTypeRecasts {
76 | users = append(users, r.User)
77 | }
78 | }
79 | userStr := buildUserList(users)
80 | return fmt.Sprintf("%s %s recasted your post", EmojiRecyle, userStr)
81 | case api.NotificationsTypeReply:
82 | return fmt.Sprintf("%s %s replied to your post", EmojiComment, n.Cast.Author.DisplayName)
83 | case api.NotificationsTypeMention:
84 | return fmt.Sprintf("%s %s mentioned you in a post",
85 | NewStyle().Bold(true).Foreground(activeColor).Render("@"), n.Cast.Author.DisplayName,
86 | )
87 |
88 | default:
89 | return "unknown notification type: " + string(n.Type)
90 |
91 | }
92 | }
93 |
94 | func (i *notifItem) Description() string {
95 | switch i.Type {
96 | case api.NotificationsTypeLikes, api.NotificationsTypeRecasts:
97 | if i.Cast != nil {
98 | return i.Cast.Text
99 | }
100 | for _, r := range i.Reactions {
101 | if r.Object == api.CastReactionObjTypeLikes {
102 | if r.Cast.Object == "cast_dehydrated" {
103 | return r.Cast.Hash
104 | }
105 | return r.Cast.Text
106 | }
107 | }
108 | return "?"
109 | case api.NotificationsTypeReply, api.NotificationsTypeMention:
110 | return i.Cast.Text
111 |
112 | }
113 | return ""
114 |
115 | }
116 |
117 | type NotificationsView struct {
118 | app *App
119 | list *list.Model
120 | w, h int
121 | active bool
122 | items []list.Item
123 | }
124 |
125 | func NewNotificationsView(app *App) *NotificationsView {
126 | d := list.NewDefaultDelegate()
127 | d.SetHeight(2)
128 | d.ShowDescription = true
129 |
130 | l := list.New([]list.Item{}, d, 100, 100)
131 | l.KeyMap.CursorUp.SetKeys("k", "up")
132 | l.KeyMap.CursorDown.SetKeys("j", "down")
133 | l.KeyMap.Quit.SetKeys("ctrl+c")
134 | l.Title = "notifications"
135 | l.SetShowTitle(true)
136 | l.SetFilteringEnabled(false)
137 | l.SetShowFilter(false)
138 | l.SetShowHelp(true)
139 | l.SetShowStatusBar(true)
140 | l.SetShowPagination(true)
141 |
142 | return &NotificationsView{app: app, list: &l}
143 | }
144 |
145 | func (m *NotificationsView) SetSize(w, h int) {
146 | m.w, m.h = w, h
147 | m.list.SetWidth(w)
148 | m.list.SetHeight(h)
149 | }
150 | func (m *NotificationsView) Active() bool {
151 | return m.active
152 | }
153 | func (m *NotificationsView) SetActive(active bool) {
154 | m.active = active
155 | }
156 |
157 | func (m *NotificationsView) Init() tea.Cmd {
158 | return getNotificationsCmd(m.app.client, m.app.ctx.signer)
159 | }
160 |
161 | func (m *NotificationsView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
162 | switch msg := msg.(type) {
163 | case tea.WindowSizeMsg:
164 | m.SetSize(msg.Width, msg.Height)
165 | return m, nil
166 |
167 | case *notificationsMsg:
168 | items := []list.Item{}
169 | for _, n := range msg.notifications {
170 | items = append(items, ¬ifItem{n})
171 | }
172 | m.items = items
173 | m.list.SetItems(items)
174 | return m, nil
175 |
176 | case tea.KeyMsg:
177 | switch msg.String() {
178 | case "q":
179 | return m, tea.Quit
180 | case "enter":
181 | item, ok := m.list.SelectedItem().(*notifItem)
182 | if !ok {
183 | return m, noOp()
184 | }
185 | switch item.Type {
186 | case api.NotificationsTypeLikes, api.NotificationsTypeRecasts, api.NotificationsTypeReply, api.NotificationsTypeMention:
187 | return m, tea.Sequence(
188 | m.app.FocusCast(),
189 | selectCast(item.Cast),
190 | )
191 | }
192 | return m, noOp()
193 | }
194 | l, cmd := m.list.Update(msg)
195 | m.list = &l
196 | return m, cmd
197 | }
198 |
199 | return m, nil
200 | }
201 |
202 | func (m *NotificationsView) View() string {
203 | return NewStyle().Width(m.w).Height(m.h).Render(m.list.View())
204 | }
205 |
--------------------------------------------------------------------------------
/api/channel.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "log"
9 | "net/http"
10 | "time"
11 |
12 | "github.com/treethought/tofui/db"
13 | )
14 |
15 | type ChannelsResponse struct {
16 | Channels []*Channel
17 | Next struct {
18 | Cursor *string `json:"cursor"`
19 | } `json:"next"`
20 | }
21 | type ChannelResponse struct {
22 | Channel *Channel `json:"channel"`
23 | ViewerContext ViewerContext `json:"viewer_context"`
24 | }
25 |
26 | type Channel struct {
27 | ID string `json:"id"`
28 | URL string `json:"url"`
29 | Name string `json:"name"`
30 | Description string `json:"description"`
31 | FollowerCount int32 `json:"follower_count"`
32 | Object string `json:"object"`
33 | ImageURL string `json:"image_url"`
34 | CreatedAt uint `json:"created_at"`
35 | ParentURL string `json:"parent_url"`
36 | Lead User `json:"lead"`
37 | Hosts []User `json:"hosts"`
38 | }
39 |
40 | func cacheChannels(channels []*Channel) {
41 | for _, ch := range channels {
42 | key := fmt.Sprintf("channel:%s", ch.ParentURL)
43 | mkey := fmt.Sprintf("channelurl:%s", ch.ID)
44 | _ = db.GetDB().Set([]byte(mkey), []byte(ch.ParentURL))
45 |
46 | if d, err := json.Marshal(ch); err == nil {
47 | if err := db.GetDB().Set([]byte(key), []byte(d)); err != nil {
48 | log.Println("failed to cache channel: ", err)
49 | }
50 | }
51 | }
52 | }
53 |
54 | func (c *Client) GetChannelUrlById(id string) string {
55 | key := fmt.Sprintf("channelurl:%s", id)
56 | cached, err := db.GetDB().Get([]byte(key))
57 | if err != nil {
58 | return ""
59 | }
60 | return string(cached)
61 | }
62 |
63 | func (c *Client) GetUserChannels(fid uint64, active bool, opts ...RequestOption) ([]*Channel, error) {
64 | var path string
65 | if active {
66 | path = "/channel/user"
67 | } else {
68 | path = "/user/channels"
69 | }
70 | opts = append(opts, WithFID(fid))
71 |
72 | var resp ChannelsResponse
73 | if err := c.doRequestInto(context.TODO(), path, &resp, opts...); err != nil {
74 | return nil, err
75 | }
76 | if resp.Channels == nil {
77 | return nil, errors.New("no channels found")
78 | }
79 | cacheChannels(resp.Channels)
80 |
81 | return resp.Channels, nil
82 | }
83 |
84 | func (c *Client) SearchChannel(q string) ([]*Channel, error) {
85 | path := "/channel/search"
86 | opts := []RequestOption{WithQuery("q", q)}
87 |
88 | var resp ChannelsResponse
89 | if err := c.doRequestInto(context.TODO(), path, &resp, opts...); err != nil {
90 | return nil, err
91 | }
92 | if resp.Channels == nil {
93 | return nil, errors.New("no channels found")
94 | }
95 | cacheChannels(resp.Channels)
96 | return resp.Channels, nil
97 | }
98 |
99 | func (c *Client) GetChannelByParentUrl(q string) (*Channel, error) {
100 | // TODO: cache once and do mappiing from name to url
101 | key := fmt.Sprintf("channel:%s", q)
102 | cached, err := db.GetDB().Get([]byte(key))
103 | if err == nil {
104 | ch := &Channel{}
105 | if err := json.Unmarshal(cached, ch); err != nil {
106 | log.Fatal("failed to unmarshal cached channel: ", err)
107 | }
108 | return ch, nil
109 | }
110 | return c.fetchChannel(q, "parent_url")
111 | }
112 |
113 | func (c *Client) GetChannelById(q string) (*Channel, error) {
114 | purl := c.GetChannelUrlById(q)
115 | if purl != "" {
116 | return c.GetChannelByParentUrl(purl)
117 | }
118 | return c.fetchChannel(q, "id")
119 | }
120 |
121 | func (c *Client) fetchChannel(q, ttype string) (*Channel, error) {
122 | path := "/channel"
123 |
124 | opts := []RequestOption{WithQuery("id", q), WithQuery("type", ttype)}
125 |
126 | var resp ChannelResponse
127 | err := c.doRequestInto(context.TODO(), path, &resp, opts...)
128 | if err != nil {
129 | return nil, err
130 | }
131 | if resp.Channel.Name == "" {
132 | return nil, errors.New("channel name empty")
133 | }
134 | cacheChannels([]*Channel{resp.Channel})
135 | return resp.Channel, nil
136 |
137 | }
138 |
139 | func (c *Client) FetchAllChannels() error {
140 | var resp ChannelsResponse
141 | var res *http.Response
142 |
143 | defer db.GetDB().Set([]byte("channelsloaded"), []byte(fmt.Sprintf("%d", time.Now().Unix())))
144 |
145 | for {
146 | if res != nil {
147 | res.Body.Close()
148 | }
149 | url := c.buildEndpoint(fmt.Sprintf("/channel/list?limit=50"))
150 | if resp.Next.Cursor != nil {
151 | url += fmt.Sprintf("&cursor=%s", *resp.Next.Cursor)
152 | }
153 | req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url, nil)
154 | if err != nil {
155 | return err
156 | }
157 | req.Header.Add("accept", "application/json")
158 | req.Header.Add("api_key", c.apiKey)
159 |
160 | res, err = c.c.Do(req)
161 | if err != nil {
162 | return err
163 | }
164 |
165 | if res.StatusCode != http.StatusOK {
166 | return fmt.Errorf("failed to get channels: %s", res.Status)
167 | }
168 |
169 | if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
170 | return err
171 | }
172 | cacheChannels(resp.Channels)
173 |
174 | if resp.Next.Cursor == nil {
175 | break
176 | }
177 | }
178 | log.Println("channels loaded")
179 |
180 | return nil
181 | }
182 |
183 | func (c *Client) GetCachedChannelIds() ([]string, error) {
184 | prefix := []byte("channelurl:")
185 | keys, err := db.GetDB().GetKeys(prefix)
186 | if err != nil {
187 | log.Println("failed to get keys: ", err)
188 | return nil, err
189 | }
190 | ids := make([]string, 0)
191 | for _, k := range keys {
192 | name := string(k[len(prefix):])
193 | ids = append(ids, name)
194 | }
195 | return ids, nil
196 | }
197 |
--------------------------------------------------------------------------------
/ui/cast_item.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/charmbracelet/bubbles/spinner"
7 | tea "github.com/charmbracelet/bubbletea"
8 | "github.com/charmbracelet/glamour"
9 | "github.com/charmbracelet/lipgloss"
10 |
11 | "github.com/treethought/tofui/api"
12 | )
13 |
14 | var (
15 | subtle = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
16 | highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}
17 | special = lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"}
18 |
19 | displayNameStyle = NewStyle().
20 | MarginRight(5).
21 | Foreground(highlight)
22 |
23 | usernameStyle = NewStyle()
24 |
25 | imgStyle = NewStyle()
26 |
27 | headerStyle = NewStyle().BorderBottom(true)
28 |
29 | infoStyle = NewStyle().
30 | MarginLeft(5).MarginRight(5).
31 | BorderStyle(lipgloss.NormalBorder()).
32 | BorderTop(true).
33 | BorderForeground(subtle).AlignHorizontal(lipgloss.Center)
34 |
35 | contentStyle = NewStyle()
36 |
37 | md, _ = glamour.NewTermRenderer(
38 | // detect background color and pick either the default dark or light theme
39 | glamour.WithAutoStyle(),
40 | // wrap output at specific width (default is 80)
41 | glamour.WithWordWrap(80),
42 | )
43 | )
44 |
45 | func UsernameHeader(user *api.User, img *ImageModel) string {
46 | if user == nil {
47 | return spinner.New().View()
48 | }
49 | return headerStyle.Render(lipgloss.JoinHorizontal(lipgloss.Center,
50 | img.View(),
51 | lipgloss.JoinHorizontal(lipgloss.Top,
52 | displayNameStyle.Render(
53 | user.DisplayName,
54 | ),
55 | usernameStyle.Render(
56 | fmt.Sprintf("@%s", user.Username),
57 | ),
58 | ),
59 | ),
60 | )
61 | }
62 |
63 | func CastStats(cast *api.Cast, margin int) string {
64 | if cast == nil {
65 | return spinner.New().View()
66 | }
67 | liked := EmojiEmptyLike
68 | if cast.ViewerContext.Liked {
69 | liked = EmojiLike
70 | }
71 | stats := lipgloss.JoinHorizontal(lipgloss.Top,
72 | NewStyle().Render(fmt.Sprintf("%d ", cast.Replies.Count)),
73 | NewStyle().MarginRight(margin).Render(EmojiComment),
74 | NewStyle().Render(fmt.Sprintf("%d ", cast.Reactions.LikesCount)),
75 | NewStyle().MarginRight(margin).Render(liked),
76 | NewStyle().Render(fmt.Sprintf("%d ", cast.Reactions.RecastsCount)),
77 | NewStyle().MarginRight(margin).Render(EmojiRecyle),
78 | )
79 | return stats
80 |
81 | }
82 |
83 | func CastContent(cast *api.Cast, maxHeight int, imgs ...ImageModel) string {
84 | if cast == nil {
85 | return spinner.New().View()
86 | }
87 | m, err := md.Render(cast.Text)
88 | if err != nil {
89 | m = cast.Text
90 | }
91 | return contentStyle.MaxHeight(maxHeight).Render(m)
92 | }
93 |
94 | func getCastChannelCmd(client *api.Client, cast *api.Cast) tea.Cmd {
95 | return func() tea.Msg {
96 | if cast.ParentURL == "" {
97 | return nil
98 | }
99 | ch, err := client.GetChannelByParentUrl(cast.ParentURL)
100 | if err != nil {
101 | return &channelInfoErrMsg{err, cast.Hash, cast.ParentURL}
102 | }
103 | return &channelInfoMsg{ch, cast.Hash, cast.ParentURL}
104 | }
105 | }
106 |
107 | type channelInfoMsg struct {
108 | channel *api.Channel
109 | cast string
110 | parentURL string
111 | }
112 |
113 | type channelInfoErrMsg struct {
114 | err error
115 | cast string
116 | parentURL string
117 | }
118 |
119 | type CastFeedItem struct {
120 | app *App
121 | cast *api.Cast
122 | channel string
123 | channelURL string
124 | pfp *ImageModel
125 | compact bool
126 | }
127 |
128 | // NewCastFeedItem displays a cast in compact form within a list
129 | // implements list.Item (and tea.Model only for updating image)
130 | func NewCastFeedItem(app *App, cast *api.Cast, compact bool) (*CastFeedItem, tea.Cmd) {
131 | c := &CastFeedItem{
132 | app: app,
133 | cast: cast,
134 | pfp: NewImage(true, true, special),
135 | compact: compact,
136 | }
137 | c.pfp.SetURL(cast.Author.PfpURL, false)
138 |
139 | cmds := []tea.Cmd{
140 | c.pfp.Render(),
141 | getCastChannelCmd(app.client, cast),
142 | }
143 |
144 | if c.compact {
145 | c.pfp.SetSize(2, 1)
146 | } else {
147 | c.pfp.SetSize(4, 4)
148 | }
149 | return c, tea.Batch(cmds...)
150 | }
151 |
152 | func (m *CastFeedItem) Init() tea.Cmd { return nil }
153 |
154 | func (m *CastFeedItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
155 | m = &CastFeedItem{
156 | app: m.app,
157 | cast: m.cast,
158 | channel: m.channel,
159 | pfp: m.pfp,
160 | compact: m.compact,
161 | }
162 | cmds := []tea.Cmd{}
163 | switch msg := msg.(type) {
164 | case *channelInfoErrMsg:
165 | if msg.cast != m.cast.Hash {
166 | return m, nil
167 | }
168 | case *channelInfoMsg:
169 | if msg.cast != m.cast.Hash {
170 | return m, nil
171 | }
172 | m.channel = msg.channel.Name
173 | m.channelURL = msg.parentURL
174 | }
175 |
176 | if m.pfp.Matches(msg) {
177 | pfp, cmd := m.pfp.Update(msg)
178 | m.pfp = pfp
179 | return m, cmd
180 |
181 | }
182 |
183 | return m, tea.Batch(cmds...)
184 | }
185 |
186 | func (m *CastFeedItem) View() string { return "" }
187 |
188 | func (m *CastFeedItem) AsRow(ch, stats bool) []string {
189 | cols := []string{}
190 | if ch {
191 | cols = append(cols, fmt.Sprintf("/%s", m.channel))
192 | }
193 | if stats {
194 | cols = append(cols, CastStats(m.cast, 2))
195 | } else {
196 | }
197 | cols = append(cols, m.cast.Author.DisplayName, m.cast.Text)
198 | return cols
199 | }
200 |
201 | func (i *CastFeedItem) Title() string {
202 | return UsernameHeader(&i.cast.Author, i.pfp)
203 | }
204 |
205 | func (i *CastFeedItem) Description() string {
206 | return CastContent(i.cast, 3)
207 | }
208 |
209 | func (i *CastFeedItem) FilterValue() string {
210 | return i.cast.Author.Username
211 | }
212 |
--------------------------------------------------------------------------------
/ui/cast_details.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | "log"
6 |
7 | "github.com/charmbracelet/bubbles/viewport"
8 | tea "github.com/charmbracelet/bubbletea"
9 | "github.com/charmbracelet/lipgloss"
10 |
11 | "github.com/treethought/tofui/api"
12 | )
13 |
14 | var style = NewStyle()
15 | var statsStyle = NewStyle()
16 | var castHeaderStyle = NewStyle().Margin(1, 1).Align(lipgloss.Top)
17 |
18 | type CastView struct {
19 | app *App
20 | cast *api.Cast
21 | img *ImageModel
22 | pfp *ImageModel
23 | replies *RepliesView
24 | vp *viewport.Model
25 | header *viewport.Model
26 | hasImg bool
27 | help *HelpView
28 |
29 | pubReply *PublishInput
30 | w, h int
31 | }
32 |
33 | func NewCastView(app *App, cast *api.Cast) *CastView {
34 | vp := viewport.New(0, 0)
35 | hp := viewport.New(0, 0)
36 | hp.Style = NewStyle().BorderBottom(true).BorderStyle(lipgloss.RoundedBorder())
37 | c := &CastView{
38 | app: app,
39 | cast: cast,
40 | pfp: NewImage(true, true, special),
41 | img: NewImage(true, true, special),
42 | replies: NewRepliesView(app),
43 | vp: &vp,
44 | header: &hp,
45 | pubReply: NewPublishInput(app),
46 | hasImg: false,
47 | help: NewHelpView(app, CastViewKeyMap),
48 | }
49 | c.pfp.SetSize(4, 4)
50 | c.help.SetFull(false)
51 | return c
52 | }
53 |
54 | func (m *CastView) Clear() {
55 | m.cast = nil
56 | m.pubReply.SetFocus(false)
57 | m.pubReply.SetActive(false)
58 | m.replies.Clear()
59 | m.img.Clear()
60 | m.pfp.Clear()
61 | }
62 |
63 | func (m *CastView) LikeCast() tea.Cmd {
64 | if m.cast == nil {
65 | return nil
66 | }
67 | return likeCastCmd(m.app.client, m.app.ctx.signer, m.cast)
68 | }
69 |
70 | func (m *CastView) OpenCast() tea.Cmd {
71 | if m.cast == nil {
72 | return nil
73 | }
74 | return OpenURL(fmt.Sprintf("https://warpcast.com/%s/%s", m.cast.Author.Username, m.cast.Hash))
75 | }
76 |
77 | func (m *CastView) Reply() {
78 | if m.cast == nil {
79 | return
80 | }
81 | m.pubReply.SetActive(true)
82 | m.pubReply.SetFocus(true)
83 | }
84 |
85 | func (m *CastView) ViewProfile() tea.Cmd {
86 | if m.cast == nil {
87 | return nil
88 | }
89 |
90 | userFid := m.cast.Author.FID
91 | return tea.Sequence(
92 | m.app.FocusProfile(),
93 | getUserCmd(m.app.client, userFid, m.app.ctx.signer.FID),
94 | getUserFeedCmd(m.app.client, userFid, m.app.ctx.signer.FID),
95 | )
96 | }
97 | func (m *CastView) ViewChannel() tea.Cmd {
98 | if m.cast == nil || m.cast.ParentURL == "" {
99 | return nil
100 | }
101 |
102 | return tea.Batch(
103 | getChannelFeedCmd(m.app.client, m.cast.ParentURL),
104 | fetchChannelCmd(m.app.client, m.cast.ParentURL),
105 | m.app.FocusChannel(),
106 | )
107 | }
108 |
109 | func (m *CastView) ViewParent() tea.Cmd {
110 | if m.cast == nil || m.cast.ParentHash == "" {
111 | return nil
112 | }
113 | return func() tea.Msg {
114 | cast, err := m.app.client.GetCastWithReplies(m.app.ctx.signer, m.cast.ParentHash)
115 | if err != nil {
116 | log.Println("failed to get parent cast", err)
117 | return nil
118 | }
119 |
120 | return m.SetCast(cast)
121 | }
122 | }
123 |
124 | func (m *CastView) SetCast(cast *api.Cast) tea.Cmd {
125 | m.Clear()
126 | m.cast = cast
127 | m.pfp.SetURL(m.cast.Author.PfpURL, false)
128 | m.pfp.SetSize(4, 4)
129 | cmds := []tea.Cmd{
130 | m.replies.SetOpHash(m.cast.Hash),
131 | m.pubReply.SetContext(m.cast.Hash, m.cast.ParentURL, m.cast.Author.FID),
132 | m.pfp.Render(),
133 | }
134 | m.hasImg = false
135 | if len(m.cast.Embeds) > 0 {
136 | m.hasImg = true
137 | m.img.SetURL(m.cast.Embeds[0].URL, true)
138 | cmds = append(cmds, m.resize(), m.img.Render())
139 | }
140 | return tea.Sequence(cmds...)
141 | }
142 |
143 | func (m *CastView) Init() tea.Cmd {
144 | return nil
145 | }
146 |
147 | func min(a, b int) int {
148 | if a < b {
149 | return a
150 | }
151 | return b
152 | }
153 |
154 | func (m *CastView) resize() tea.Cmd {
155 | cmds := []tea.Cmd{}
156 | fx, fy := style.GetFrameSize()
157 | w := min(m.w-fx, int(float64(GetWidth())*0.75))
158 | h := min(m.h-fy, GetHeight()-4)
159 |
160 | m.help.SetSize(m.w, 1)
161 |
162 | m.header.Height = min(10, int(float64(h)*0.2))
163 |
164 | hHeight := lipgloss.Height(m.header.View())
165 |
166 | cy := h - hHeight
167 |
168 | m.vp.Width = w - fx
169 | m.vp.Height = int(float64(cy) * 0.5) //- fy
170 |
171 | if m.hasImg {
172 | q := int(float64(cy) * 0.25)
173 | m.vp.Height = q
174 | m.img.SetSize(m.vp.Width/2, q)
175 |
176 | cmds = append(cmds, m.img.Render())
177 | } else {
178 | m.img.Clear()
179 | }
180 | m.replies.SetSize(w, int(float64(cy)*0.5))
181 |
182 | m.pubReply.SetSize(w, h)
183 | return tea.Batch(cmds...)
184 | }
185 |
186 | func (m *CastView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
187 | switch msg := msg.(type) {
188 | case tea.WindowSizeMsg:
189 | m.w, m.h = msg.Width, msg.Height
190 | return m, m.resize()
191 |
192 | case *ctxInfoMsg:
193 | _, cmd := m.pubReply.Update(msg)
194 | return m, cmd
195 |
196 | case tea.KeyMsg:
197 | if m.pubReply.Active() {
198 | _, cmd := m.pubReply.Update(msg)
199 | return m, cmd
200 | }
201 | if CastViewKeyMap.HandleMsg(m, msg) != nil {
202 | return m, CastViewKeyMap.HandleMsg(m, msg)
203 | }
204 | }
205 | m.vp.SetContent(CastContent(m.cast, 10))
206 | m.header.SetContent(m.castHeader())
207 | cmds := []tea.Cmd{}
208 |
209 | _, rcmd := m.replies.Update(msg)
210 | cmds = append(cmds, rcmd)
211 |
212 | if m.img.Matches(msg) {
213 | _, icmd := m.img.Update(msg)
214 | _, pcmd := m.pfp.Update(msg)
215 | return m, tea.Batch(icmd, pcmd)
216 | }
217 |
218 | return m, tea.Batch(cmds...)
219 | }
220 |
221 | func (m *CastView) castHeader() string {
222 | if m.cast == nil {
223 | return ""
224 | }
225 | return castHeaderStyle.Render(
226 | lipgloss.JoinVertical(lipgloss.Center,
227 | UsernameHeader(&m.cast.Author, m.pfp),
228 | CastStats(m.cast, 1),
229 | ),
230 | )
231 |
232 | }
233 |
234 | func (m *CastView) View() string {
235 | if m.pubReply.Active() {
236 | return m.pubReply.View()
237 | }
238 |
239 | return style.Height(m.h).Render(
240 | lipgloss.JoinVertical(lipgloss.Center,
241 | m.header.View(),
242 | m.vp.View(),
243 | m.img.View(),
244 | m.help.View(),
245 | m.replies.View(),
246 | ),
247 | )
248 | }
249 |
--------------------------------------------------------------------------------
/ui/sidebar.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | "log"
6 |
7 | "github.com/charmbracelet/bubbles/list"
8 | tea "github.com/charmbracelet/bubbletea"
9 | "github.com/charmbracelet/lipgloss"
10 |
11 | "github.com/treethought/tofui/api"
12 | )
13 |
14 | type Sidebar struct {
15 | app *App
16 | active bool
17 | nav *list.Model
18 | account *api.User
19 | pfp *ImageModel
20 | w, h int
21 | }
22 |
23 | type currentAccountMsg struct {
24 | account *api.User
25 | }
26 |
27 | func getCurrentAccount(client *api.Client, signer *api.Signer) tea.Cmd {
28 | return func() tea.Msg {
29 | if signer == nil {
30 | return nil
31 | }
32 | user, err := client.GetUserByFID(signer.FID, signer.FID)
33 | if err != nil {
34 | log.Println("error getting current account: ", err)
35 | return nil
36 | }
37 | return ¤tAccountMsg{account: user}
38 | }
39 | }
40 |
41 | type sidebarItem struct {
42 | name string
43 | value string
44 | icon string
45 | itype string
46 | }
47 |
48 | func (m *sidebarItem) FilterValue() string {
49 | return m.name
50 | }
51 | func (m *sidebarItem) View() string {
52 | return m.value
53 | }
54 | func (m *sidebarItem) Title() string {
55 | return m.name
56 | }
57 |
58 | func (i *sidebarItem) Description() string {
59 | return ""
60 | }
61 |
62 | var navStyle = NewStyle().Margin(2, 2, 0, 0).BorderRight(true).BorderStyle(lipgloss.RoundedBorder())
63 |
64 | func NewSidebar(app *App) *Sidebar {
65 | d := list.NewDefaultDelegate()
66 | d.SetHeight(1)
67 | d.ShowDescription = false
68 |
69 | l := list.New([]list.Item{}, d, 0, 0)
70 | l.KeyMap.CursorUp.SetKeys("k", "up")
71 | l.KeyMap.CursorDown.SetKeys("j", "down")
72 | l.KeyMap.Quit.SetKeys("ctrl+c")
73 | l.Title = "tofui"
74 | l.SetShowTitle(true)
75 | l.SetFilteringEnabled(false)
76 | l.SetShowHelp(false)
77 | l.SetShowStatusBar(false)
78 |
79 | pfp := NewImage(true, true, special)
80 | pfp.SetSize(1, 1)
81 |
82 | return &Sidebar{app: app, nav: &l, pfp: pfp}
83 | }
84 |
85 | func (m *Sidebar) SetSize(w, h int) {
86 | x, y := navStyle.GetFrameSize()
87 | m.w, m.h = w-x, h-y
88 | m.nav.SetWidth(m.w)
89 | m.nav.SetHeight(m.h)
90 | m.pfp.SetSize(4, 4)
91 | }
92 |
93 | func (m *Sidebar) Active() bool {
94 | return m.active
95 | }
96 | func (m *Sidebar) SetActive(active bool) {
97 | m.active = active
98 | }
99 |
100 | func (m *Sidebar) navHeader() []list.Item {
101 | items := []list.Item{}
102 | if api.GetSigner(m.app.ctx.pk) != nil {
103 | items = append(items, &sidebarItem{name: "profile"})
104 | items = append(items, &sidebarItem{name: "notifications"})
105 | }
106 | items = append(items, &sidebarItem{name: "feed"})
107 | items = append(items, &sidebarItem{name: "--channels---", value: "--channels--", icon: "🏠"})
108 | return items
109 | }
110 |
111 | func (m *Sidebar) Init() tea.Cmd {
112 | log.Println("sidebar init")
113 | var fid uint64
114 | if m.app.ctx.signer != nil {
115 | fid = m.app.ctx.signer.FID
116 | }
117 | return tea.Batch(
118 | m.nav.SetItems(m.navHeader()),
119 | getChannelsCmd(m.app.client, true, fid),
120 | getCurrentAccount(m.app.client, m.app.ctx.signer),
121 | m.pfp.Init(),
122 | )
123 | }
124 |
125 | func selectProfileCmd(fid uint64) tea.Cmd {
126 | return func() tea.Msg {
127 | return SelectProfileMsg{fid}
128 | }
129 | }
130 |
131 | func (m *Sidebar) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
132 | switch msg := msg.(type) {
133 | case *channelListMsg:
134 | items := m.navHeader()
135 | for _, c := range msg.channels {
136 | items = append(items, &sidebarItem{name: c.Name, value: c.ParentURL, itype: "channel"})
137 | }
138 | return m, m.nav.SetItems(items)
139 |
140 | case tea.KeyMsg:
141 | if msg.String() == "ctrl+c" {
142 | return m, tea.Quit
143 | }
144 | if !m.active {
145 | return m, nil
146 | }
147 | if msg.String() == "enter" {
148 | currentItem := m.nav.SelectedItem().(*sidebarItem)
149 | if currentItem.name == "profile" {
150 | m.SetActive(false)
151 | log.Println("profile selected")
152 | fid := api.GetSigner(m.app.ctx.pk).FID
153 | if fid == 0 {
154 | return m, nil
155 | }
156 | return m, tea.Sequence(
157 | m.app.FocusProfile(),
158 | getUserCmd(m.app.client, fid, m.app.ctx.signer.FID),
159 | getUserFeedCmd(m.app.client, fid, m.app.ctx.signer.FID),
160 | )
161 | }
162 | if currentItem.name == "notifications" {
163 | m.SetActive(false)
164 | log.Println("notifications selected")
165 | return m, tea.Sequence(m.app.FocusNotifications())
166 | }
167 | if currentItem.name == "feed" {
168 | m.SetActive(false)
169 | log.Println("feed selected")
170 | return m, tea.Sequence(m.app.FocusFeed(), getDefaultFeedCmd(m.app.client, m.app.ctx.signer))
171 | }
172 | if currentItem.itype == "channel" {
173 | m.SetActive(false)
174 | m.app.SetNavName(fmt.Sprintf("channel: %s", currentItem.name))
175 | return m, tea.Batch(
176 | getChannelFeedCmd(m.app.client, currentItem.value),
177 | fetchChannelCmd(m.app.client, currentItem.value),
178 | m.app.FocusChannel(),
179 | )
180 | }
181 | }
182 | case *currentAccountMsg:
183 | m.account = msg.account
184 | m.pfp.SetURL(m.account.PfpURL, false)
185 | return m, m.pfp.Render()
186 |
187 | }
188 |
189 | //update list size if window size changes
190 | cmds := []tea.Cmd{}
191 | l, ncmd := m.nav.Update(msg)
192 | m.nav = &l
193 | cmds = append(cmds, ncmd)
194 |
195 | pfp, pcmd := m.pfp.Update(msg)
196 | m.pfp = pfp
197 | cmds = append(cmds, pcmd)
198 | return m, tea.Batch(cmds...)
199 | }
200 | func (m *Sidebar) View() string {
201 | ss := navStyle
202 | if m.account == nil {
203 | return navStyle.Render(m.nav.View())
204 | }
205 | if m.active {
206 | ss = navStyle.BorderForeground(activeColor)
207 |
208 | }
209 |
210 | accountStyle := NewStyle().
211 | Border(lipgloss.RoundedBorder(), true, false, true).
212 | Width(m.w).
213 | MaxWidth(m.w).
214 | Align(lipgloss.Center, lipgloss.Center).Margin(0).Padding(0)
215 |
216 | account := accountStyle.Render(
217 | lipgloss.JoinHorizontal(lipgloss.Center,
218 | m.pfp.View(),
219 | lipgloss.JoinVertical(lipgloss.Left,
220 | m.account.DisplayName,
221 | fmt.Sprintf("@%s", m.account.Username),
222 | ),
223 | ),
224 | )
225 |
226 | return ss.Render(
227 | lipgloss.JoinVertical(lipgloss.Top,
228 | m.nav.View(),
229 | account,
230 | ),
231 | )
232 | }
233 |
--------------------------------------------------------------------------------
/ui/keybindings.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/charmbracelet/bubbles/key"
7 | tea "github.com/charmbracelet/bubbletea"
8 | )
9 |
10 | type casetViewKeymap struct {
11 | LikeCast key.Binding
12 | ViewProfile key.Binding
13 | ViewChannel key.Binding
14 | ViewParent key.Binding
15 | Comment key.Binding
16 | OpenCast key.Binding
17 | }
18 |
19 | func (k casetViewKeymap) ShortHelp() []key.Binding {
20 | return []key.Binding{
21 | k.LikeCast,
22 | k.ViewParent,
23 | k.Comment,
24 | }
25 | }
26 | func (k casetViewKeymap) All() []key.Binding {
27 | return []key.Binding{
28 | k.LikeCast,
29 | k.ViewProfile,
30 | k.ViewChannel,
31 | k.ViewParent,
32 | k.Comment,
33 | k.OpenCast,
34 | }
35 | }
36 |
37 | func (k casetViewKeymap) FullHelp() [][]key.Binding {
38 | return [][]key.Binding{
39 | k.All(),
40 | }
41 | }
42 |
43 | func (k casetViewKeymap) HandleMsg(c *CastView, msg tea.KeyMsg) tea.Cmd {
44 | switch {
45 | case key.Matches(msg, k.LikeCast):
46 | return c.LikeCast()
47 | case key.Matches(msg, k.ViewProfile):
48 | return c.ViewProfile()
49 | case key.Matches(msg, k.ViewChannel):
50 | return c.ViewChannel()
51 | case key.Matches(msg, k.ViewParent):
52 | return c.ViewParent()
53 | case key.Matches(msg, k.Comment):
54 | c.Reply()
55 | return noOp()
56 | case key.Matches(msg, k.OpenCast):
57 | return c.OpenCast()
58 | }
59 | return nil
60 | }
61 |
62 | var CastViewKeyMap = casetViewKeymap{
63 | LikeCast: key.NewBinding(
64 | key.WithKeys("l"),
65 | key.WithHelp("l", "like cast"),
66 | ),
67 | ViewProfile: key.NewBinding(
68 | key.WithKeys("p"),
69 | key.WithHelp("p", "view profile"),
70 | ),
71 | ViewChannel: key.NewBinding(
72 | key.WithKeys("c"),
73 | key.WithHelp("c", "view channel"),
74 | ),
75 | ViewParent: key.NewBinding(
76 | key.WithKeys("t"),
77 | key.WithHelp("t", "view parent"),
78 | ),
79 | Comment: key.NewBinding(
80 | key.WithKeys("r"),
81 | key.WithHelp("r", "reply"),
82 | ),
83 | OpenCast: key.NewBinding(
84 | key.WithKeys("o"),
85 | key.WithHelp("o", "open in browser"),
86 | ),
87 | }
88 |
89 | type feedKeymap struct {
90 | ViewCast key.Binding
91 | LikeCast key.Binding
92 | ViewProfile key.Binding
93 | ViewChannel key.Binding
94 | OpenCast key.Binding
95 | }
96 |
97 | func (k feedKeymap) ShortHelp() []key.Binding {
98 | return []key.Binding{
99 | k.LikeCast,
100 | k.ViewProfile,
101 | k.ViewChannel,
102 | }
103 | }
104 | func (k feedKeymap) All() []key.Binding {
105 | return []key.Binding{
106 | k.ViewCast,
107 | k.LikeCast,
108 | k.ViewProfile,
109 | k.ViewChannel,
110 | k.OpenCast,
111 | }
112 | }
113 |
114 | func (k feedKeymap) HandleMsg(f *FeedView, msg tea.KeyMsg) tea.Cmd {
115 | switch {
116 | case key.Matches(msg, k.ViewCast):
117 | log.Println("ViewCast")
118 | return f.SelectCurrentItem()
119 | case key.Matches(msg, k.LikeCast):
120 | log.Println("LikeCast")
121 | return f.LikeCurrentItem()
122 | case key.Matches(msg, k.ViewProfile):
123 | log.Println("ViewProfile")
124 | return f.ViewCurrentProfile()
125 | case key.Matches(msg, k.ViewChannel):
126 | log.Println("ViewChannel")
127 | return f.ViewCurrentChannel()
128 | case key.Matches(msg, k.OpenCast):
129 | log.Println("OpenCast")
130 | return f.OpenCurrentItem()
131 | }
132 | return nil
133 | }
134 |
135 | var FeedKeyMap = feedKeymap{
136 | ViewCast: key.NewBinding(
137 | key.WithKeys("enter"),
138 | key.WithHelp("enter", "view cast"),
139 | ),
140 | LikeCast: key.NewBinding(
141 | key.WithKeys("l"),
142 | key.WithHelp("l", "like cast"),
143 | ),
144 | ViewProfile: key.NewBinding(
145 | key.WithKeys("p"),
146 | key.WithHelp("p", "view profile"),
147 | ),
148 | ViewChannel: key.NewBinding(
149 | key.WithKeys("c"),
150 | key.WithHelp("c", "view channel"),
151 | ),
152 | OpenCast: key.NewBinding(
153 | key.WithKeys("o"),
154 | key.WithHelp("o", "open in browser "),
155 | ),
156 | }
157 |
158 | type navKeymap struct {
159 | Feed key.Binding
160 |
161 | Publish key.Binding
162 | QuickSelect key.Binding
163 | Help key.Binding
164 | ToggleSidebarFocus key.Binding
165 | ToggleSidebarVisibility key.Binding
166 | Previous key.Binding
167 | ViewNotifications key.Binding
168 | }
169 |
170 | func (k navKeymap) ShortHelp() []key.Binding {
171 | return []key.Binding{
172 | k.Feed,
173 | k.QuickSelect,
174 | k.ViewNotifications,
175 | k.Help,
176 | }
177 | }
178 |
179 | func (k navKeymap) All() []key.Binding {
180 | return []key.Binding{
181 | k.Feed, k.QuickSelect,
182 | k.Publish,
183 | k.ViewNotifications,
184 | k.Previous,
185 | k.Help,
186 | k.ToggleSidebarFocus, k.ToggleSidebarVisibility,
187 | }
188 | }
189 |
190 | var NavKeyMap = navKeymap{
191 | Feed: key.NewBinding(
192 | key.WithKeys("F", "1"),
193 | key.WithHelp("F/1", "feed"),
194 | ),
195 | Publish: key.NewBinding(
196 | key.WithKeys("P"),
197 | key.WithHelp("P", "publish cast"),
198 | ),
199 | QuickSelect: key.NewBinding(
200 | key.WithKeys("ctrl+k"),
201 | key.WithHelp("ctrl+k", "quick select"),
202 | ),
203 | Help: key.NewBinding(
204 | key.WithKeys("?"),
205 | key.WithHelp("?", "help"),
206 | ),
207 | ToggleSidebarFocus: key.NewBinding(
208 | key.WithKeys("tab"),
209 | key.WithHelp("tab", "toggle sidebar focus"),
210 | ),
211 | ToggleSidebarVisibility: key.NewBinding(
212 | key.WithKeys("shift+tab"),
213 | key.WithHelp("shift+tab", "toggle sidebar focus"),
214 | ),
215 | Previous: key.NewBinding(
216 | key.WithKeys("esc"),
217 | key.WithHelp("esc", "focus previous"),
218 | ),
219 | ViewNotifications: key.NewBinding(
220 | key.WithKeys("N"),
221 | key.WithHelp("N", "view notifications"),
222 | ),
223 | }
224 |
225 | func (k navKeymap) HandleMsg(a *App, msg tea.KeyMsg) tea.Cmd {
226 | switch {
227 | case key.Matches(msg, k.Feed):
228 | // TODO cleanup
229 | // reset params for user's feed
230 | var cmd tea.Cmd
231 | a.SetNavName("feed")
232 | a.sidebar.SetActive(false)
233 | return tea.Sequence(cmd, a.FocusFeed())
234 |
235 | case key.Matches(msg, k.Publish):
236 | a.FocusPublish()
237 | return noOp()
238 |
239 | case key.Matches(msg, k.QuickSelect):
240 | a.FocusQuickSelect()
241 | return nil
242 |
243 | case key.Matches(msg, k.Help):
244 | a.FocusHelp()
245 |
246 | case key.Matches(msg, k.ViewNotifications):
247 | log.Println("ViewNotifications")
248 | return a.FocusNotifications()
249 |
250 | case key.Matches(msg, k.Previous):
251 | return a.FocusPrev()
252 |
253 | case key.Matches(msg, k.ToggleSidebarVisibility):
254 | if a.showSidebar {
255 | a.showSidebar = false
256 | a.sidebar.SetActive(false)
257 | return noOp()
258 | }
259 | a.showSidebar = true
260 | a.sidebar.SetActive(true)
261 | return noOp()
262 |
263 | case key.Matches(msg, k.ToggleSidebarFocus):
264 | if a.quickSelect.Active() {
265 | _, cmd := a.quickSelect.Update(msg)
266 | return cmd
267 | }
268 | if !a.showSidebar {
269 | return nil
270 | }
271 | a.sidebar.SetActive(!a.sidebar.Active())
272 | }
273 |
274 | return nil
275 | }
276 |
277 | type kmap struct {
278 | nav navKeymap
279 | feed feedKeymap
280 | cast casetViewKeymap
281 | }
282 |
283 | var GlobalKeyMap = kmap{
284 | nav: NavKeyMap,
285 | feed: FeedKeyMap,
286 | cast: CastViewKeyMap,
287 | }
288 |
289 | func (k kmap) ShortHelp() []key.Binding {
290 | return append(k.nav.ShortHelp(), k.feed.ShortHelp()...)
291 | }
292 |
293 | func (k kmap) FullHelp() [][]key.Binding {
294 | return [][]key.Binding{
295 | k.nav.All(),
296 | k.feed.All(),
297 | k.cast.All(),
298 | }
299 | }
300 |
301 | func noOp() tea.Cmd {
302 | return func() tea.Msg {
303 | return nil
304 | }
305 | }
306 |
--------------------------------------------------------------------------------
/cmd/ssh.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "context"
5 | _ "embed"
6 | "errors"
7 | "fmt"
8 | "log"
9 | "net/http"
10 | "os"
11 | "os/signal"
12 | "strconv"
13 | "sync"
14 | "syscall"
15 | "text/template"
16 | "time"
17 |
18 | tea "github.com/charmbracelet/bubbletea"
19 | wlog "github.com/charmbracelet/log"
20 | "github.com/charmbracelet/ssh"
21 | "github.com/charmbracelet/wish"
22 | "github.com/charmbracelet/wish/accesscontrol"
23 | "github.com/charmbracelet/wish/activeterm"
24 | "github.com/charmbracelet/wish/bubbletea"
25 | "github.com/charmbracelet/wish/logging"
26 | "github.com/muesli/termenv"
27 | "github.com/spf13/cobra"
28 |
29 | "github.com/treethought/tofui/api"
30 | "github.com/treethought/tofui/db"
31 | "github.com/treethought/tofui/ui"
32 | )
33 |
34 | var (
35 | //go:embed siwn.html
36 | sinwhtml []byte
37 | host = "0.0.0.0"
38 | port = "42069"
39 | )
40 |
41 | type Server struct {
42 | // user may have more than one session
43 | // map of pk to active programs
44 | prgmSessions map[string][]*tea.Program
45 | mux sync.Mutex
46 | }
47 |
48 | // sshCmd represents the ssh command
49 | var sshCmd = &cobra.Command{
50 | Use: "ssh",
51 | Short: "serve tofui over ssh",
52 | Run: func(cmd *cobra.Command, args []string) {
53 | defer logFile.Close()
54 | defer db.GetDB().Close()
55 | sv := &Server{
56 | prgmSessions: make(map[string][]*tea.Program),
57 | }
58 | go sv.startSigninHTTPServer()
59 | sv.runSSHServer()
60 | },
61 | }
62 |
63 | func (sv *Server) runSSHServer() {
64 | addr := host + ":" + port
65 | s, err := wish.NewServer(
66 | wish.WithAddress(addr),
67 | wish.WithHostKeyPath(".ssh/tofui_ed25519"),
68 | // Accept any public key.
69 | ssh.PublicKeyAuth(func(ssh.Context, ssh.PublicKey) bool { return true }),
70 | // Do not accept password auth.
71 | ssh.PasswordAuth(func(ssh.Context, string) bool { return false }),
72 | wish.WithMiddleware(
73 | sv.teaMiddleware(),
74 | activeterm.Middleware(),
75 | logging.Middleware(),
76 | accesscontrol.Middleware(),
77 | ),
78 | )
79 | if err != nil {
80 | wlog.Error("Could not start server", "error", err)
81 | }
82 |
83 | done := make(chan os.Signal, 1)
84 | signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
85 | wlog.Info("Starting SSH server", "host", host, "port", port)
86 | go func() {
87 | if err = s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
88 | wlog.Error("Could not start server", "error", err)
89 | done <- nil
90 | }
91 | }()
92 |
93 | <-done
94 | wlog.Info("Stopping SSH server")
95 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
96 | defer func() { cancel() }()
97 | if err := s.Shutdown(ctx); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
98 | wlog.Error("Could not stop server", "error", err)
99 | }
100 | }
101 |
102 | func (sv *Server) teaMiddleware() wish.Middleware {
103 | teaHandler := func(s ssh.Session) *tea.Program {
104 | _, _, active := s.Pty()
105 | if !active {
106 | wish.Fatalln(s, "no active terminal, skipping")
107 | return nil
108 | }
109 |
110 | renderer := bubbletea.MakeRenderer(s)
111 | app, err := ui.NewSSHApp(cfg, s, renderer)
112 | if err != nil {
113 | wlog.Error("failed to create app", "error", err)
114 | return nil
115 | }
116 | if app.PublicKey() == "" {
117 | log.Fatal("new app's public key is nil")
118 | }
119 |
120 | p := tea.NewProgram(app, append(bubbletea.MakeOptions(s), tea.WithAltScreen())...)
121 |
122 | sv.mux.Lock()
123 | sv.prgmSessions[app.PublicKey()] = append(sv.prgmSessions[app.PublicKey()], p)
124 | sv.mux.Unlock()
125 | log.Println("new app session added: ", app.PublicKey())
126 | return p
127 | }
128 | return bubbletea.MiddlewareWithProgramHandler(teaHandler, termenv.ANSI256)
129 | }
130 |
131 | func (sv *Server) HttpHandleSignin(w http.ResponseWriter, r *http.Request) {
132 | tmpl, err := template.New("signin").Parse(string(sinwhtml))
133 | if err != nil {
134 | log.Fatal("failed to parse template: ", err)
135 | }
136 | data := struct {
137 | ClientID string
138 | PublicKey string
139 | BaseUrl string
140 | }{
141 | ClientID: cfg.Neynar.ClientID,
142 | BaseUrl: cfg.BaseURL(),
143 | }
144 | query := r.URL.Query()
145 | pk := query.Get("pk")
146 | if pk == "" {
147 | w.Write([]byte("error: missing pk"))
148 | w.WriteHeader(http.StatusBadRequest)
149 | return
150 | }
151 | data.PublicKey = pk
152 | err = tmpl.Execute(w, data)
153 | if err != nil {
154 | log.Println("failed to execute template: ", err)
155 | http.Error(w, err.Error(), http.StatusInternalServerError)
156 | }
157 | }
158 |
159 | func (sv *Server) HttpHandleSigninSuccess(w http.ResponseWriter, r *http.Request) {
160 | query := r.URL.Query()
161 | pk := query.Get("pk")
162 | if pk == "" {
163 | w.Write([]byte("error: missing pk"))
164 | w.WriteHeader(http.StatusBadRequest)
165 | return
166 | }
167 | fid, err := strconv.ParseUint(query.Get("fid"), 10, 64)
168 | if err != nil {
169 | w.Write([]byte("error: missing fid"))
170 | w.WriteHeader(http.StatusBadRequest)
171 | return
172 | }
173 | signerUUid := query.Get("signer_uuid")
174 |
175 | sv.signinCallback(fid, signerUUid, pk)
176 | w.Write([]byte("success, you may now close the window and return to your terminal."))
177 | }
178 |
179 | func (sv *Server) signinCallback(fid uint64, uuid, pk string) {
180 | client := api.NewClient(cfg)
181 | signer := &api.Signer{FID: fid, UUID: uuid, PublicKey: pk}
182 | if user, err := client.GetUserByFID(fid, fid); err == nil {
183 | signer.Username = user.Username
184 | signer.DisplayName = user.DisplayName
185 | }
186 | api.SetSigner(signer)
187 |
188 | var prgms []*tea.Program
189 | var ok bool
190 | sv.mux.Lock()
191 | prgms, ok = sv.prgmSessions[pk]
192 | sv.mux.Unlock()
193 | if !ok || len(prgms) == 0 {
194 | log.Println("failed to send signin msg, session not found")
195 | return
196 | }
197 | for _, p := range prgms {
198 | if p == nil {
199 | log.Println("nil program")
200 | continue
201 | }
202 | p.Send(&ui.UpdateSignerMsg{Signer: signer})
203 | }
204 | fmt.Println("signed in as:", signer.Username)
205 | }
206 |
207 | func (sv *Server) HttpHandleIndex(w http.ResponseWriter, r *http.Request) {
208 | w.Write([]byte(`
209 |
210 |
211 | tofui
212 |
213 |
214 | tofui
215 | Terminally On Farcaster User Interface
216 |
217 |
ssh -p 42069 you@tofui.xyz
218 |
219 |
Or, visit
the repo for more info
221 |
222 |
223 |
224 | `))
225 | }
226 |
227 | func (sv *Server) startSigninHTTPServer() {
228 | ctx, cancel := context.WithCancel(context.Background())
229 | defer cancel()
230 | mux := http.NewServeMux()
231 | mux.HandleFunc("/", sv.HttpHandleIndex)
232 | mux.HandleFunc("/signin", sv.HttpHandleSignin)
233 | mux.HandleFunc("/signin/success", sv.HttpHandleSigninSuccess)
234 |
235 | srv := &http.Server{
236 | Addr: fmt.Sprintf(":%d", cfg.Server.HTTPPort),
237 | Handler: mux,
238 | }
239 |
240 | if cfg.Server.HTTPPort == 443 {
241 | cert := fmt.Sprintf("%s/%s", cfg.Server.CertsDir, "cert.pem")
242 | key := fmt.Sprintf("%s/%s", cfg.Server.CertsDir, "privkey.pem")
243 | log.Println("serving TLS on ", srv.Addr)
244 | go func() {
245 | if err := srv.ListenAndServeTLS(cert, key); err != nil {
246 | log.Println(err)
247 | }
248 | }()
249 |
250 | } else {
251 | log.Println("listening on ", srv.Addr)
252 | go func() {
253 | if err := srv.ListenAndServe(); err != nil {
254 | log.Println(err)
255 | }
256 | }()
257 |
258 | }
259 |
260 | <-ctx.Done()
261 | srv.Shutdown(context.Background())
262 |
263 | }
264 |
265 | func init() {
266 | rootCmd.AddCommand(sshCmd)
267 |
268 | }
269 |
--------------------------------------------------------------------------------
/ui/publish.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | "log"
6 |
7 | "github.com/charmbracelet/bubbles/help"
8 | "github.com/charmbracelet/bubbles/key"
9 | "github.com/charmbracelet/bubbles/textarea"
10 | "github.com/charmbracelet/bubbles/viewport"
11 | tea "github.com/charmbracelet/bubbletea"
12 | "github.com/charmbracelet/lipgloss"
13 |
14 | "github.com/treethought/tofui/api"
15 | )
16 |
17 | const confirmPrefix = "Publish cast? (y/n)"
18 |
19 | type postResponseMsg struct {
20 | err error
21 | resp *api.PostCastResponse
22 | }
23 |
24 | type ctxInfoMsg struct {
25 | user *api.User
26 | channel *api.Channel
27 | }
28 |
29 | func postCastCmd(client *api.Client, signer *api.Signer, text, parent, channel string, parentAuthor uint64) tea.Cmd {
30 | return func() tea.Msg {
31 | resp, err := client.PostCast(signer, text, parent, channel, parentAuthor)
32 | if err != nil {
33 | return &postResponseMsg{err: err}
34 | }
35 | return &postResponseMsg{resp: resp}
36 | }
37 | }
38 |
39 | type keyMap struct {
40 | Cast key.Binding
41 | Back key.Binding
42 | ChooseChannel key.Binding
43 | }
44 |
45 | func (k keyMap) ShortHelp() []key.Binding {
46 | return []key.Binding{k.Cast, k.Back, k.ChooseChannel}
47 | }
48 |
49 | func (k keyMap) FullHelp() [][]key.Binding {
50 | return [][]key.Binding{
51 | {k.Cast},
52 | {k.Back},
53 | }
54 | }
55 |
56 | var keys = keyMap{
57 | Cast: key.NewBinding(
58 | key.WithKeys("ctrl+d"),
59 | key.WithHelp("ctrl+d", "publish cast"),
60 | ),
61 | Back: key.NewBinding(
62 | key.WithKeys("esc"),
63 | key.WithHelp("esc", "back to feed"),
64 | ),
65 | ChooseChannel: key.NewBinding(
66 | key.WithKeys("ctrl+w"),
67 | key.WithHelp("ctrl+w", "choose channel"),
68 | ),
69 | }
70 |
71 | type castContext struct {
72 | channel string
73 | parent string
74 | parentAuthor uint64
75 | parentUser *api.User
76 | }
77 |
78 | type PublishInput struct {
79 | app *App
80 | keys keyMap
81 | help help.Model
82 | ta *textarea.Model
83 | vp *viewport.Model
84 | showConfirm bool
85 | active bool
86 | w, h int
87 | castCtx castContext
88 | qs *QuickSelect
89 | }
90 |
91 | func NewPublishInput(app *App) *PublishInput {
92 | ta := textarea.New()
93 | if app.ctx.signer == nil {
94 | ta.Placeholder = "please sign in to post"
95 | } else {
96 | ta.Placeholder = "publish cast..."
97 | }
98 | ta.CharLimit = 1024
99 | ta.ShowLineNumbers = false
100 | ta.Prompt = ""
101 |
102 | vp := viewport.New(0, 0)
103 | vp.SetContent(ta.View())
104 |
105 | qs := NewQuickSelect(app)
106 |
107 | return &PublishInput{ta: &ta, vp: &vp, keys: keys, help: help.New(), app: app, qs: qs}
108 | }
109 |
110 | func (m *PublishInput) Init() tea.Cmd {
111 | m.qs.SetOnSelect(func(i *selectItem) tea.Cmd {
112 | m.qs.SetActive(false)
113 | return m.SetContext("", i.name, 0)
114 | })
115 | return m.qs.Init()
116 | }
117 |
118 | func (m *PublishInput) Active() bool {
119 | return m.active
120 | }
121 | func (m *PublishInput) SetActive(active bool) {
122 | m.active = active
123 | }
124 |
125 | func (m *PublishInput) SetSize(w, h int) {
126 | m.w = w
127 | m.h = h
128 | m.ta.SetWidth(w)
129 | m.ta.SetHeight(h)
130 | m.vp.Width = w
131 | m.vp.Height = h
132 | m.qs.SetSize(w, h)
133 | }
134 |
135 | func (m *PublishInput) SetContext(parent, channelParentUrl string, parentAuthor uint64) tea.Cmd {
136 | return func() tea.Msg {
137 | m.castCtx.channel = channelParentUrl
138 | m.castCtx.parent = parent
139 | m.castCtx.parentAuthor = parentAuthor
140 | m.castCtx.parentUser = nil
141 | var viewer uint64
142 | if m.app.ctx.signer != nil {
143 | viewer = m.app.ctx.signer.FID
144 | }
145 | var parentUser *api.User
146 | var channel *api.Channel
147 | var err error
148 | if parentAuthor > 0 {
149 | parentUser, err = m.app.client.GetUserByFID(parentAuthor, viewer)
150 | if err != nil {
151 | log.Println("error getting parent author: ", err)
152 | return nil
153 | }
154 | }
155 | if channelParentUrl != "" {
156 | channel, err = m.app.client.GetChannelByParentUrl(channelParentUrl)
157 | if err != nil {
158 | log.Println("error getting channel by parent url, trying channel id: ", err)
159 | channel, err = m.app.client.GetChannelById(channelParentUrl)
160 | if err != nil {
161 | log.Println("error getting channel by id: ", err)
162 | return nil
163 | }
164 | }
165 | }
166 | return &ctxInfoMsg{user: parentUser, channel: channel}
167 | }
168 | }
169 |
170 | func (m *PublishInput) SetFocus(focus bool) {
171 | if focus {
172 | m.ta.Focus()
173 | return
174 | }
175 | m.ta.Blur()
176 | }
177 | func (m *PublishInput) Clear() {
178 | m.ta.Reset()
179 | m.vp.SetContent(m.ta.View())
180 | m.showConfirm = false
181 | m.SetFocus(false)
182 | m.SetContext("", "", 0)
183 | }
184 |
185 | func (m *PublishInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
186 | switch msg := msg.(type) {
187 | case *ctxInfoMsg:
188 | m.castCtx.parentUser = msg.user
189 | if msg.channel != nil {
190 | m.castCtx.channel = msg.channel.Name
191 | }
192 | return m, nil
193 | case *postResponseMsg:
194 | if msg.err != nil {
195 | log.Println("error posting cast: ", msg.err)
196 | m.vp.SetContent(NewStyle().Foreground(lipgloss.Color("#ff0000")).Render("error posting cast!"))
197 | return m, nil
198 | }
199 | if msg.resp == nil || !msg.resp.Success {
200 | m.vp.SetContent(NewStyle().Foreground(lipgloss.Color("#ff0000")).Render("error posting cast!"))
201 | return m, nil
202 | }
203 | log.Println("cast posted: ", msg.resp.Cast.Hash)
204 | m.Clear()
205 | m.SetActive(false)
206 | return m, m.app.GoToCast(msg.resp.Cast.Hash)
207 | case tea.KeyMsg:
208 | if msg.String() == "ctrl+c" {
209 | return nil, tea.Quit
210 | }
211 | if m.qs.Active() {
212 | _, cmd := m.qs.Update(msg)
213 | return m, cmd
214 | }
215 |
216 | switch {
217 | case key.Matches(msg, m.keys.Cast):
218 | m.showConfirm = true
219 | return m, nil
220 | case key.Matches(msg, m.keys.Back):
221 | m.active = false
222 | return nil, nil
223 | case key.Matches(msg, m.keys.ChooseChannel):
224 | m.qs.SetActive(true)
225 | }
226 |
227 | if m.showConfirm {
228 | if msg.String() == "y" || msg.String() == "Y" {
229 | return m, postCastCmd(
230 | m.app.client, m.app.ctx.signer,
231 | m.ta.Value(), m.castCtx.parent,
232 | m.castCtx.channel, m.castCtx.parentAuthor,
233 | )
234 | } else if msg.String() == "n" || msg.String() == "N" || msg.String() == "esc" {
235 | m.showConfirm = false
236 | return m, nil
237 | }
238 | }
239 | }
240 | if m.app.ctx.signer == nil {
241 | m.ta.Blur()
242 | m.ta.SetValue("please sign in to post")
243 | return m, nil
244 | }
245 |
246 | var cmds []tea.Cmd
247 | _, cmd := m.qs.Update(msg)
248 | cmds = append(cmds, cmd)
249 |
250 | ta, tcmd := m.ta.Update(msg)
251 | m.ta = &ta
252 | cmds = append(cmds, tcmd)
253 | return m, tea.Batch(cmds...)
254 | }
255 |
256 | func (m *PublishInput) viewConfirm() string {
257 | header := NewStyle().BorderBottom(true).BorderStyle(lipgloss.NormalBorder()).Render(confirmPrefix)
258 | return lipgloss.JoinVertical(lipgloss.Top,
259 | header, m.ta.View())
260 | }
261 |
262 | func (m *PublishInput) View() string {
263 | content := m.ta.View()
264 | if m.showConfirm {
265 | content = m.viewConfirm()
266 | } else if m.qs.Active() {
267 | content = m.qs.View()
268 |
269 | } else {
270 | content = lipgloss.JoinVertical(lipgloss.Top,
271 | content,
272 | m.help.View(m.keys),
273 | )
274 | }
275 |
276 | titleText := "publish cast"
277 | if m.castCtx.parentUser != nil {
278 | titleText = fmt.Sprintf("reply to @%s", m.castCtx.parentUser.Username)
279 | } else if m.castCtx.channel != "" {
280 | titleText = fmt.Sprintf("publish cast to channel: /%s", m.castCtx.channel)
281 | }
282 |
283 | titleStyle := NewStyle().Foreground(lipgloss.Color("#874BFD")).BorderBottom(true).BorderStyle(lipgloss.NormalBorder())
284 | title := titleStyle.Render(titleText)
285 |
286 | dialog := lipgloss.Place(m.w/2, m.h/2,
287 | lipgloss.Center, lipgloss.Center,
288 | lipgloss.JoinVertical(lipgloss.Top,
289 | title,
290 | dialogBoxStyle.Width(m.w).Height(m.h).Render(content),
291 | ),
292 | // lipgloss.WithWhitespaceChars("猫咪"),
293 | lipgloss.WithWhitespaceChars("~~"),
294 | lipgloss.WithWhitespaceForeground(subtle),
295 | )
296 | return dialog
297 | }
298 |
--------------------------------------------------------------------------------
/ui/image.go:
--------------------------------------------------------------------------------
1 | // modified from https://github.com/mistakenelf/teacup/blob/main/image/image.go
2 | package ui
3 |
4 | import (
5 | "bytes"
6 | "context"
7 | "encoding/json"
8 | "fmt"
9 | "image"
10 | "image/draw"
11 | _ "image/gif"
12 | _ "image/jpeg"
13 | _ "image/png"
14 | "io"
15 | "log"
16 | "net/http"
17 | "strings"
18 | "time"
19 |
20 | "github.com/PuerkitoBio/goquery"
21 | "github.com/charmbracelet/bubbles/viewport"
22 | tea "github.com/charmbracelet/bubbletea"
23 | "github.com/charmbracelet/lipgloss"
24 | "github.com/disintegration/imaging"
25 | "github.com/lucasb-eyer/go-colorful"
26 | _ "golang.org/x/image/bmp"
27 | _ "golang.org/x/image/tiff"
28 | _ "golang.org/x/image/webp"
29 |
30 | "github.com/treethought/tofui/db"
31 | )
32 |
33 | type imageDownloadMsg struct {
34 | url string
35 | filename string
36 | }
37 | type convertImageToStringMsg struct {
38 | url string
39 | str string
40 | }
41 |
42 | type downloadError struct {
43 | err error
44 | url string
45 | }
46 |
47 | type decodeError struct {
48 | err error
49 | url string
50 | }
51 |
52 | const (
53 | padding = 1
54 | )
55 |
56 | // ToString converts an image to a string representation of an image.
57 | func ToString(width int, img image.Image) string {
58 | img = imaging.Resize(img, width, 0, imaging.Lanczos)
59 | b := img.Bounds()
60 | imageWidth := b.Max.X
61 | h := b.Max.Y
62 | str := strings.Builder{}
63 |
64 | for heightCounter := 0; heightCounter < h; heightCounter += 2 {
65 | for x := imageWidth; x < width; x += 2 {
66 | str.WriteString(" ")
67 | }
68 |
69 | for x := 0; x < imageWidth; x++ {
70 | c1, _ := colorful.MakeColor(img.At(x, heightCounter))
71 | color1 := lipgloss.Color(c1.Hex())
72 | c2, _ := colorful.MakeColor(img.At(x, heightCounter+1))
73 | color2 := lipgloss.Color(c2.Hex())
74 | str.WriteString(NewStyle().Foreground(color1).
75 | Background(color2).Render("▀"))
76 | }
77 |
78 | str.WriteString("\n")
79 | }
80 |
81 | return str.String()
82 | }
83 |
84 | type embedPreview struct {
85 | Title string
86 | Description string
87 | ImageURL string
88 | }
89 |
90 | func getEmbedPreview(url string) (*embedPreview, error) {
91 | if cached, err := db.GetDB().Get([]byte(fmt.Sprintf("embed:%s", url))); err == nil {
92 | p := &embedPreview{}
93 | if err := json.Unmarshal(cached, p); err != nil {
94 | return p, nil
95 | }
96 | }
97 | resp, err := http.Get(url)
98 | if err != nil {
99 | return nil, err
100 | }
101 | defer resp.Body.Close()
102 |
103 | doc, err := goquery.NewDocumentFromReader(resp.Body)
104 | if err != nil {
105 | log.Println("failed getting document", url, err)
106 | return nil, err
107 | }
108 |
109 | preview := &embedPreview{
110 | Title: doc.Find("meta[property='og:title']").AttrOr("content", ""),
111 | Description: doc.Find("meta[property='og:description']").AttrOr("content", ""),
112 | ImageURL: doc.Find("meta[property='og:image']").AttrOr("content", ""),
113 | }
114 |
115 | // If Open Graph tags aren't present, you can use fallbacks or other metadata, e.g., from Twitter cards.
116 | if preview.Title == "" {
117 | preview.Title = doc.Find("meta[name='twitter:title']").AttrOr("content", "")
118 | }
119 | if preview.Description == "" {
120 | preview.Description = doc.Find("meta[name='twitter:description']").AttrOr("content", "")
121 | }
122 | if preview.ImageURL == "" {
123 | preview.ImageURL = doc.Find("meta[name='twitter:image']").AttrOr("content", "")
124 | }
125 | if preview.ImageURL != "" {
126 | if d, err := json.Marshal(preview); err == nil {
127 | if err := db.GetDB().Set([]byte(fmt.Sprintf("embed:%s", url)), d); err != nil {
128 | log.Println("error caching embed", err)
129 | return preview, nil
130 | }
131 | }
132 | }
133 |
134 | return preview, nil
135 | }
136 |
137 | func getImageCmd(width int, url string, embed bool) tea.Cmd {
138 | return func() tea.Msg {
139 | data, err := getImage(width, url, embed)
140 | if err != nil {
141 | return downloadError{err: err, url: url}
142 | }
143 | imgString, err := convertImageToString(width, data)
144 | if err != nil {
145 | return decodeError{err: err, url: url}
146 | }
147 | return convertImageToStringMsg{url: url, str: imgString}
148 | }
149 | }
150 |
151 | func getImage(width int, url string, embed bool) ([]byte, error) {
152 | if strings.HasSuffix(url, ".gif") || strings.HasSuffix(url, ".svg") {
153 | return nil, fmt.Errorf("gif not supported")
154 | }
155 |
156 | if embed {
157 | ep, err := getEmbedPreview(url)
158 | if err == nil && ep.ImageURL != "" {
159 | url = ep.ImageURL
160 | }
161 | }
162 |
163 | cached, err := db.GetDB().Get([]byte(fmt.Sprintf("img:%s", url)))
164 | if err == nil {
165 | return cached, nil
166 | }
167 |
168 | ctx, cancel := context.WithTimeout(context.TODO(), 5*time.Second)
169 | defer cancel()
170 |
171 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
172 | if err != nil {
173 | return nil, err
174 | }
175 | // set headers that would be sent in browser like user agent so we don't get ratelimited
176 | req.Header.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3")
177 | req.Header.Set("Accept", "image/png,image/jpeg,image/*,*/*;q=0.8")
178 | req.Header.Add("Accept-Language", "en-US,en;q=0.5")
179 | // req.Header.Add("Accept-Encoding", "gzip, deflate, br")
180 | req.Header.Add("Connection", "keep-alive")
181 | req.Header.Add("Upgrade-Insecure-Requests", "1")
182 | req.Header.Add("Cache-Control", "max-age=0")
183 | resp, err := http.DefaultClient.Do(req)
184 | if err != nil {
185 | return nil, err
186 | }
187 | if resp.StatusCode != 200 {
188 | return nil, fmt.Errorf("bad status code: %d", resp.StatusCode)
189 | }
190 | d, err := io.ReadAll(resp.Body)
191 | if err != nil {
192 | return nil, err
193 | }
194 | if err := db.GetDB().Set([]byte(fmt.Sprintf("img:%s", url)), d); err != nil {
195 | log.Println("error saving image", err)
196 | }
197 | return d, nil
198 | }
199 |
200 | func convertImageToString(width int, ib []byte) (string, error) {
201 | ir := bytes.NewReader(ib)
202 |
203 | img, _, err := image.Decode(ir)
204 | if err != nil {
205 | return "", err
206 | }
207 | // Check if the decoded image is of type NRGBA (non-alpha-premultiplied color)
208 | // If it's not, convert it to NRGBA
209 | // needed for bubbletea
210 | if _, ok := img.(*image.NRGBA); !ok {
211 | rgba := image.NewNRGBA(img.Bounds())
212 | draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src)
213 | img = rgba
214 | }
215 |
216 | return ToString(width, img), nil
217 | }
218 |
219 | // ImageModel represents the properties of a code bubble.
220 | type ImageModel struct {
221 | Viewport *viewport.Model
222 | BorderColor lipgloss.AdaptiveColor
223 | Active bool
224 | Borderless bool
225 | URL string
226 | isEmbed bool
227 | ImageString string
228 | }
229 |
230 | // New creates a new instance of code.
231 | func NewImage(active, borderless bool, borderColor lipgloss.AdaptiveColor) *ImageModel {
232 | viewPort := viewport.New(0, 0)
233 | border := lipgloss.NormalBorder()
234 |
235 | if borderless {
236 | border = lipgloss.HiddenBorder()
237 | }
238 |
239 | viewPort.Style = NewStyle().
240 | PaddingLeft(padding).
241 | PaddingRight(padding).
242 | Border(border).
243 | BorderForeground(borderColor)
244 |
245 | return &ImageModel{
246 | Viewport: &viewPort,
247 | Active: active,
248 | Borderless: borderless,
249 | BorderColor: borderColor,
250 | }
251 | }
252 |
253 | // Init initializes the code bubble.
254 | func (m ImageModel) Init() tea.Cmd {
255 | return nil
256 | }
257 |
258 | func (m *ImageModel) Clear() {
259 | m.URL = ""
260 | m.ImageString = ""
261 | m.Viewport.SetContent("")
262 | m.Viewport.Width = 0
263 | m.Viewport.Height = 0
264 | }
265 |
266 | func (m *ImageModel) Render() tea.Cmd {
267 | if m.Viewport.Width == 0 {
268 | return nil
269 | }
270 | if m.URL == "" {
271 | return nil
272 | }
273 | return getImageCmd(m.Viewport.Width, m.URL, m.isEmbed)
274 | }
275 |
276 | func (m *ImageModel) SetURL(url string, embed bool) {
277 | m.URL = url
278 | m.isEmbed = embed
279 | }
280 |
281 | // SetBorderColor sets the current color of the border.
282 | func (m *ImageModel) SetBorderColor(color lipgloss.AdaptiveColor) {
283 | m.BorderColor = color
284 | }
285 |
286 | func (m *ImageModel) SetSize(w, h int) {
287 | m.Viewport.Width = w
288 | m.Viewport.Height = h
289 |
290 | border := lipgloss.NormalBorder()
291 |
292 | if m.Borderless {
293 | border = lipgloss.HiddenBorder()
294 | }
295 |
296 | m.Viewport.Style = NewStyle().
297 | PaddingLeft(padding).
298 | PaddingRight(padding).
299 | Border(border).
300 | BorderForeground(m.BorderColor)
301 | }
302 |
303 | // SetIsActive sets if the bubble is currently active
304 | func (m *ImageModel) SetIsActive(active bool) {
305 | m.Active = active
306 | }
307 |
308 | // GotoTop jumps to the top of the viewport.
309 | func (m *ImageModel) GotoTop() {
310 | m.Viewport.GotoTop()
311 | }
312 |
313 | // SetBorderless sets weather or not to show the border.
314 | func (m *ImageModel) SetBorderless(borderless bool) {
315 | m.Borderless = borderless
316 | }
317 |
318 | func (m *ImageModel) Matches(msg tea.Msg) bool {
319 | switch msg.(type) {
320 | case convertImageToStringMsg:
321 | return true
322 | case downloadError:
323 | return true
324 | case decodeError:
325 | return true
326 | }
327 | return false
328 |
329 | }
330 |
331 | // Update handles updating the UI of a code bubble.
332 | func (m *ImageModel) Update(msg tea.Msg) (*ImageModel, tea.Cmd) {
333 | var (
334 | cmds []tea.Cmd
335 | )
336 |
337 | switch msg := msg.(type) {
338 | case convertImageToStringMsg:
339 | if msg.url == m.URL && msg.str != "" {
340 | m.ImageString = NewStyle().
341 | Width(m.Viewport.Width).
342 | Height(m.Viewport.Height).
343 | Render(msg.str)
344 | m.Viewport.SetContent(m.ImageString)
345 | m.SetSize(m.Viewport.Width, m.Viewport.Height)
346 | }
347 | case downloadError:
348 | if msg.url == m.URL {
349 | m.ImageString = NewStyle().
350 | Width(m.Viewport.Width).
351 | Height(m.Viewport.Height).
352 | Render("Error: " + msg.err.Error())
353 | }
354 | case decodeError:
355 | if msg.url == m.URL {
356 | m.ImageString = NewStyle().
357 | Width(m.Viewport.Width).
358 | Height(m.Viewport.Height).
359 | Render("Error: " + msg.err.Error())
360 | }
361 | }
362 |
363 | return m, tea.Batch(cmds...)
364 | }
365 |
366 | // View returns a string representation of the code bubble.
367 | func (m ImageModel) View() string {
368 | border := lipgloss.NormalBorder()
369 |
370 | if m.Borderless {
371 | border = lipgloss.HiddenBorder()
372 | }
373 |
374 | m.Viewport.Style = NewStyle().
375 | PaddingLeft(padding).
376 | PaddingRight(padding).
377 | Border(border).
378 | BorderForeground(m.BorderColor)
379 |
380 | return m.Viewport.View()
381 | }
382 |
--------------------------------------------------------------------------------
/ui/app.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "crypto/sha256"
5 | "fmt"
6 | "log"
7 |
8 | tea "github.com/charmbracelet/bubbletea"
9 | "github.com/charmbracelet/lipgloss"
10 | "github.com/charmbracelet/ssh"
11 |
12 | "github.com/treethought/tofui/api"
13 | "github.com/treethought/tofui/config"
14 | )
15 |
16 | // TODO provide to models
17 | var renderer *lipgloss.Renderer = lipgloss.DefaultRenderer()
18 |
19 | var activeColor = lipgloss.AdaptiveColor{Dark: "#874BFD", Light: "#874BFD"}
20 |
21 | var (
22 | mainStyle = lipgloss.NewStyle().Margin(0).Padding(0).Border(lipgloss.RoundedBorder())
23 | )
24 |
25 | func NewStyle() lipgloss.Style {
26 | return renderer.NewStyle()
27 | }
28 |
29 | type UpdateSignerMsg struct {
30 | Signer *api.Signer
31 | }
32 |
33 | type navNameMsg struct {
34 | name string
35 | }
36 |
37 | func navNameCmd(name string) tea.Cmd {
38 | return func() tea.Msg {
39 | return navNameMsg{name: name}
40 | }
41 | }
42 |
43 | type SelectCastMsg struct {
44 | cast *api.Cast
45 | }
46 |
47 | type AppContext struct {
48 | s ssh.Session
49 | signer *api.Signer
50 | pk string
51 | }
52 |
53 | type App struct {
54 | ctx *AppContext
55 | client *api.Client
56 | cfg *config.Config
57 | pubonly bool
58 | focusedModel tea.Model
59 | focused string
60 | navname string
61 | sidebar *Sidebar
62 | showSidebar bool
63 | prev string
64 | prevName string
65 | quickSelect *QuickSelect
66 | publish *PublishInput
67 | statusLine *StatusLine
68 | notifications *NotificationsView
69 |
70 | splash *SplashView
71 | help *HelpView
72 |
73 | feed *FeedView
74 | channel *FeedView
75 | profile *Profile
76 | cast *CastView
77 | }
78 |
79 | func (a *App) PublicKey() string {
80 | return a.ctx.pk
81 | }
82 |
83 | func NewSSHApp(cfg *config.Config, s ssh.Session, r *lipgloss.Renderer) (*App, error) {
84 | if r != nil {
85 | renderer = r
86 | }
87 | if s.PublicKey() == nil {
88 | return nil, fmt.Errorf("public key is nil")
89 | }
90 | // hash the pk so we can use it in auth flow
91 | h := sha256.New()
92 | pkBytes := s.PublicKey().Marshal()
93 | h.Write(pkBytes)
94 | pk := fmt.Sprintf("%x", h.Sum(nil))
95 |
96 | signer := api.GetSigner(pk)
97 | if signer != nil {
98 | log.Println("logged in as: ", signer.Username)
99 | }
100 |
101 | ctx := &AppContext{s: s, pk: pk, signer: signer}
102 | app := NewApp(cfg, ctx, false)
103 | return app, nil
104 | }
105 |
106 | func NewLocalApp(cfg *config.Config, pubInit bool) *App {
107 | signer := api.GetSigner("local")
108 | if signer != nil {
109 | log.Println("logged in locally as: ", signer.Username)
110 | }
111 | ctx := &AppContext{signer: signer, pk: "local"}
112 | app := NewApp(cfg, ctx, pubInit)
113 | return app
114 | }
115 |
116 | func NewApp(cfg *config.Config, ctx *AppContext, pubonly bool) *App {
117 | if ctx == nil {
118 | ctx = &AppContext{}
119 | }
120 | a := &App{
121 | showSidebar: true,
122 | ctx: ctx,
123 | client: api.NewClient(cfg),
124 | cfg: cfg,
125 | pubonly: pubonly,
126 | }
127 | a.feed = NewFeedView(a, feedTypeFollowing)
128 | a.focusedModel = a.feed
129 |
130 | a.profile = NewProfile(a)
131 |
132 | a.channel = NewFeedView(a, feedTypeChannel)
133 |
134 | a.cast = NewCastView(a, nil)
135 |
136 | a.sidebar = NewSidebar(a)
137 | a.quickSelect = NewQuickSelect(a)
138 | a.publish = NewPublishInput(a)
139 | a.statusLine = NewStatusLine(a)
140 | a.help = NewHelpView(a, GlobalKeyMap)
141 | a.notifications = NewNotificationsView(a)
142 | a.splash = NewSplashView(a)
143 | a.splash.SetActive(true)
144 | if a.ctx.signer == nil {
145 | a.splash.ShowSignin(true)
146 | }
147 | if a.pubonly {
148 | a.splash.SetActive(false)
149 | a.FocusPublish()
150 | a.SetNavName("publish")
151 | return a
152 | }
153 | a.SetNavName("feed")
154 |
155 | return a
156 | }
157 |
158 | func (a *App) SetNavName(name string) {
159 | a.prevName = a.navname
160 | a.navname = name
161 | }
162 |
163 | func (a *App) focusMain() {
164 | if a.quickSelect.Active() {
165 | a.quickSelect.SetActive(false)
166 | }
167 | if a.publish.Active() {
168 | a.publish.SetActive(false)
169 | a.publish.SetFocus(false)
170 | }
171 | a.sidebar.SetActive(false)
172 | if a.help.IsFull() {
173 | a.help.SetFull(false)
174 | }
175 | if a.notifications.Active() {
176 | a.notifications.SetActive(false)
177 | }
178 | }
179 |
180 | func (a *App) FocusPublish() {
181 | a.publish.SetActive(true)
182 | a.publish.SetFocus(true)
183 | }
184 | func (a *App) FocusHelp() {
185 | a.help.SetFull(!a.help.IsFull())
186 | }
187 | func (a *App) FocusQuickSelect() {
188 | a.quickSelect.SetActive(true)
189 | }
190 | func (a *App) FocusNotifications() tea.Cmd {
191 | a.notifications.SetActive(true)
192 | return a.notifications.Init()
193 | }
194 | func (a *App) ToggleHelp() {
195 | a.help.SetFull(!a.help.IsFull())
196 | }
197 |
198 | func (a *App) FocusFeed() tea.Cmd {
199 | a.focusMain()
200 | a.SetNavName("feed")
201 | a.focusedModel = a.feed
202 | a.focused = "feed"
203 | return nil
204 | }
205 |
206 | func (a *App) FocusProfile() tea.Cmd {
207 | a.focusMain()
208 | a.SetNavName("profile")
209 | a.focusedModel = a.profile
210 | a.focused = "profile"
211 | return a.profile.Init()
212 | }
213 |
214 | func (a *App) FocusChannel() tea.Cmd {
215 | a.focusMain()
216 | a.SetNavName("channel")
217 | a.focusedModel = a.channel
218 | a.focused = "channel"
219 | return a.channel.Init()
220 | }
221 |
222 | func (a *App) GoToCast(hash string) tea.Cmd {
223 | return func() tea.Msg {
224 | cast, err := a.client.GetCastWithReplies(a.ctx.signer, hash)
225 | if err != nil {
226 | log.Println("error getting cast: ", err)
227 | return nil
228 | }
229 | return tea.Sequence(a.FocusCast(), a.cast.SetCast(cast))
230 | }
231 | }
232 |
233 | func (a *App) FocusCast() tea.Cmd {
234 | a.focusMain()
235 | a.SetNavName("cast")
236 | a.focusedModel = a.cast
237 | a.focused = "cast"
238 | return a.cast.Init()
239 | }
240 |
241 | func (a *App) GetFocused() tea.Model {
242 | return a.focusedModel
243 | }
244 |
245 | func (a *App) FocusPrev() tea.Cmd {
246 | switch a.prev {
247 | case "feed":
248 | return a.FocusFeed()
249 | case "profile":
250 | return a.FocusProfile()
251 | case "channel":
252 | return a.FocusChannel()
253 | case "cast":
254 | return a.FocusCast()
255 | }
256 | return a.FocusFeed()
257 | }
258 |
259 | func (a *App) Init() tea.Cmd {
260 | cmds := []tea.Cmd{}
261 | cmds = append(cmds,
262 | a.splash.Init(), a.sidebar.Init(),
263 | a.quickSelect.Init(), a.publish.Init(),
264 | a.notifications.Init(),
265 | )
266 | focus := a.GetFocused()
267 | if focus != nil {
268 | cmds = append(cmds, focus.Init())
269 | }
270 | return tea.Batch(cmds...)
271 | }
272 |
273 | func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
274 |
275 | // log.Println("received msg type: ", reflect.TypeOf(msg))
276 | var cmds []tea.Cmd
277 | _, sbcmd := a.statusLine.Update(msg)
278 | cmds = append(cmds, sbcmd)
279 | switch msg := msg.(type) {
280 | case *notificationsMsg:
281 | _, cmd := a.notifications.Update(msg)
282 | return a, cmd
283 | case *UpdateSignerMsg:
284 | a.ctx.signer = msg.Signer
285 | a.splash.ShowSignin(false)
286 | log.Println("updated signer for: ", msg.Signer.Username)
287 | return a, a.Init()
288 | case navNameMsg:
289 | a.SetNavName(msg.name)
290 | return a, nil
291 | case *postResponseMsg:
292 | _, cmd := a.publish.Update(msg)
293 | return a, tea.Sequence(cmd, a.FocusPrev())
294 | case *channelListMsg:
295 | if msg.activeOnly {
296 | _, cmd := a.sidebar.Update(msg)
297 | return a, cmd
298 | }
299 | _, qcmd := a.quickSelect.Update(msg.channels)
300 | _, pcmd := a.publish.Update(msg.channels)
301 | return a, tea.Batch(qcmd, pcmd)
302 | case *feedLoadedMsg:
303 | a.splash.SetActive(false)
304 | case *channelInfoMsg:
305 | a.splash.SetInfo(msg.channel.Name)
306 | case *api.FeedResponse:
307 | // for first load
308 | a.splash.SetInfo("loading channels...")
309 | // pas through to feed or profile
310 | // case SelectProfileMsg:
311 | case SelectCastMsg:
312 | nav := fmt.Sprintf("cast by @%s", msg.cast.Author.Username)
313 | if msg.cast.ParentHash != "" {
314 | nav = fmt.Sprintf("reply by @%s", msg.cast.Author.Username)
315 | }
316 | a.SetNavName(nav)
317 | return a, tea.Sequence(
318 | a.cast.SetCast(msg.cast),
319 | a.FocusCast(),
320 | )
321 |
322 | case tea.WindowSizeMsg:
323 | SetHeight(msg.Height)
324 | SetWidth(msg.Width)
325 |
326 | a.statusLine.SetSize(msg.Width, 1)
327 | _, statusHeight := lipgloss.Size(a.statusLine.View())
328 |
329 | wx, wy := msg.Width, msg.Height-statusHeight
330 | fx, fy := mainStyle.GetFrameSize()
331 | wx = wx - fx
332 | wy = wy - fy
333 |
334 | spx := min(80, wx-10)
335 | spy := min(80, wy-10)
336 |
337 | a.splash.SetSize(spx, spy)
338 |
339 | sx := min(30, int(float64(wx)*0.2))
340 | a.sidebar.SetSize(sx, wy-statusHeight)
341 | sideWidth, _ := lipgloss.Size(a.sidebar.View())
342 |
343 | mx := wx - sideWidth
344 | mx = min(mx, int(float64(wx)*0.8))
345 |
346 | my := min(wy, int(float64(wy)*0.9))
347 |
348 | dialogX, dialogY := int(float64(mx)*0.8), int(float64(my)*0.9)
349 | a.publish.SetSize(dialogX, dialogY)
350 | a.quickSelect.SetSize(dialogX, dialogY)
351 | a.help.SetSize(dialogX, dialogY)
352 | a.notifications.SetSize(dialogX, dialogY)
353 |
354 | childMsg := tea.WindowSizeMsg{
355 | Width: mx,
356 | Height: my,
357 | }
358 |
359 | _, fcmd := a.feed.Update(childMsg)
360 | _, pcmd := a.profile.Update(childMsg)
361 | _, ccmd := a.channel.Update(childMsg)
362 | _, cscmd := a.cast.Update(childMsg)
363 |
364 | cmds = append(cmds, fcmd, pcmd, ccmd, cscmd)
365 |
366 | case tea.KeyMsg:
367 | switch msg.String() {
368 | case "ctrl+c", "q":
369 | return a, tea.Quit
370 | }
371 | cmd := NavKeyMap.HandleMsg(a, msg)
372 | if cmd != nil {
373 | return a, cmd
374 | }
375 | if a.sidebar.Active() {
376 | _, cmd := a.sidebar.Update(msg)
377 | return a, cmd
378 | }
379 | if a.splash.Active() {
380 | _, cmd := a.splash.Update(msg)
381 | return a, cmd
382 | }
383 | if a.publish.Active() {
384 | _, cmd := a.publish.Update(msg)
385 | return a, cmd
386 | }
387 |
388 | if a.notifications.Active() {
389 | _, cmd := a.notifications.Update(msg)
390 | if cmd != nil {
391 | return a, cmd
392 | }
393 | }
394 |
395 | case *currentAccountMsg:
396 | _, cmd := a.sidebar.Update(msg)
397 | a.splash.ShowSignin(false)
398 | return a, cmd
399 | }
400 | if a.publish.Active() {
401 | _, cmd := a.publish.Update(msg)
402 | return a, cmd
403 | }
404 | if a.quickSelect.Active() {
405 | q, cmd := a.quickSelect.Update(msg)
406 | a.quickSelect = q.(*QuickSelect)
407 | return a, cmd
408 | }
409 |
410 | if a.help.IsFull() {
411 | _, cmd := a.help.Update(msg)
412 | return a, cmd
413 | }
414 |
415 | if a.sidebar.Active() {
416 | _, cmd := a.sidebar.Update(msg)
417 | return a, cmd
418 | }
419 | _, scmd := a.sidebar.pfp.Update(msg)
420 | cmds = append(cmds, scmd)
421 |
422 | current := a.GetFocused()
423 | if current == nil {
424 | log.Println("no focused model")
425 | return Fallback, nil
426 | }
427 |
428 | _, cmd := current.Update(msg)
429 | cmds = append(cmds, cmd)
430 | return a, tea.Batch(cmds...)
431 |
432 | }
433 |
434 | func (a *App) View() string {
435 | focus := a.GetFocused()
436 | if focus == nil {
437 | return "no focused model"
438 | }
439 | main := focus.View()
440 | side := a.sidebar.View()
441 | if a.splash.Active() {
442 | main = lipgloss.Place(GetWidth(), GetHeight(), lipgloss.Center, lipgloss.Center, a.splash.View())
443 | return main
444 | }
445 | if a.notifications.Active() {
446 | main = a.notifications.View()
447 | }
448 |
449 | if a.publish.Active() {
450 | main = a.publish.View()
451 | }
452 | if a.quickSelect.Active() {
453 | main = a.quickSelect.View()
454 | }
455 | if !a.showSidebar {
456 | return NewStyle().Align(lipgloss.Center).Render(main)
457 | }
458 |
459 | if a.help.IsFull() {
460 | main = a.help.View()
461 | }
462 |
463 | ss := mainStyle
464 | if !a.sidebar.Active() {
465 | ss = ss.BorderForeground(activeColor)
466 | }
467 | main = ss.Render(main)
468 |
469 | return lipgloss.JoinVertical(lipgloss.Top,
470 | lipgloss.JoinHorizontal(lipgloss.Center, side, main),
471 | a.statusLine.View(),
472 | )
473 |
474 | }
475 |
--------------------------------------------------------------------------------
/ui/feed.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | "log"
6 |
7 | "github.com/charmbracelet/bubbles/key"
8 | "github.com/charmbracelet/bubbles/progress"
9 | "github.com/charmbracelet/bubbles/spinner"
10 | "github.com/charmbracelet/bubbles/table"
11 | "github.com/charmbracelet/bubbles/viewport"
12 | tea "github.com/charmbracelet/bubbletea"
13 | "github.com/charmbracelet/lipgloss"
14 |
15 | "github.com/treethought/tofui/api"
16 | )
17 |
18 | var (
19 | feedStyle = NewStyle().Margin(2, 2).Align(lipgloss.Center)
20 | channelHeaderStyle = NewStyle().Margin(1, 1).Align(lipgloss.Top).Border(lipgloss.RoundedBorder())
21 | )
22 |
23 | type feedType string
24 |
25 | var (
26 | feedTypeFollowing feedType = "following"
27 | feedTypeChannel feedType = "channel"
28 | feedTypeProfile feedType = "profile"
29 | feedTypeReplies feedType = "replies"
30 | )
31 |
32 | type feedLoadedMsg struct{}
33 |
34 | type apiErrorMsg struct {
35 | err error
36 | }
37 |
38 | type fetchChannelMsg struct {
39 | parentURL string
40 | channel *api.Channel
41 | err error
42 | }
43 |
44 | type channelFeedMsg struct {
45 | casts []*api.Cast
46 | err error
47 | }
48 |
49 | type reactMsg struct {
50 | hash string
51 | rtype string
52 | state bool
53 | }
54 |
55 | type FeedView struct {
56 | app *App
57 | table table.Model
58 | items []*CastFeedItem
59 | loading *Loading
60 | req *api.FeedRequest
61 |
62 | showChannel bool
63 | showStats bool
64 | description string
65 | descVp *viewport.Model
66 | headerImg *ImageModel
67 | feedType feedType
68 | w, h int
69 | }
70 |
71 | func getTableStyles() table.Styles {
72 | s := table.DefaultStyles()
73 | s.Header = NewStyle().Bold(true).Padding(0, 1).
74 | BorderStyle(lipgloss.NormalBorder()).
75 | BorderForeground(lipgloss.Color("240")).
76 | BorderBottom(true).
77 | Bold(false)
78 | s.Selected = NewStyle().Bold(true).
79 | Foreground(lipgloss.Color("229")).
80 | Background(lipgloss.Color("57")).
81 | Bold(false)
82 |
83 | s.Cell = NewStyle().Padding(0, 1)
84 | return s
85 |
86 | }
87 |
88 | func newTable() table.Model {
89 |
90 | t := table.New(
91 | table.WithFocused(true),
92 | table.WithKeyMap(table.KeyMap{
93 | LineUp: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
94 | LineDown: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
95 | PageUp: key.NewBinding(key.WithKeys("pageup", "K"), key.WithHelp("PgUp/K", "page up")),
96 | PageDown: key.NewBinding(key.WithKeys("pagedown", "J"), key.WithHelp("PgDn/J", "page down")),
97 | GotoTop: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("Home/g", "go to top")),
98 | GotoBottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("End/G", "go to bottom")),
99 | }),
100 | )
101 | s := getTableStyles()
102 | t.SetStyles(s)
103 | return t
104 | }
105 |
106 | func NewFeedView(app *App, ft feedType) *FeedView {
107 | p := progress.New()
108 | p.ShowPercentage = false
109 | dvp := viewport.New(0, 0)
110 |
111 | return &FeedView{
112 | app: app,
113 | table: newTable(),
114 | items: []*CastFeedItem{},
115 | loading: NewLoading(),
116 | showChannel: true,
117 | showStats: true,
118 | descVp: &dvp,
119 | headerImg: NewImage(true, true, special),
120 | feedType: ft,
121 | }
122 | }
123 |
124 | func (m *FeedView) SetDescription(desc string) {
125 | if m.feedType != feedTypeChannel {
126 | log.Println("not setting description: type: ", m.feedType)
127 | return
128 | }
129 | m.description = desc
130 | m.descVp.SetContent(desc)
131 | m.SetSize(m.w, m.h)
132 | }
133 |
134 | func (m *FeedView) SetShowChannel(show bool) {
135 | m.showChannel = show
136 | m.setTableConfig()
137 | }
138 | func (m *FeedView) SetShowStats(show bool) {
139 | m.showStats = show
140 | m.setTableConfig()
141 | }
142 |
143 | func (m *FeedView) setTableConfig() {
144 | fx, _ := feedStyle.GetFrameSize()
145 | w := m.table.Width() - fx //- 10
146 |
147 | if !m.showChannel && !m.showStats {
148 | m.table.SetColumns([]table.Column{
149 | {Title: "user", Width: int(float64(w) * 0.2)},
150 | {Title: "cast", Width: int(float64(w) * 0.8)},
151 | })
152 | return
153 | }
154 | m.table.SetColumns([]table.Column{
155 | {Title: "channel", Width: int(float64(w) * 0.2)},
156 | {Title: "", Width: int(float64(w) * 0.1)},
157 | {Title: "user", Width: int(float64(w) * 0.2)},
158 | {Title: "cast", Width: int(float64(w)*0.5) - 4},
159 | })
160 | return
161 |
162 | }
163 |
164 | func (m *FeedView) Init() tea.Cmd {
165 | m.setTableConfig()
166 | cmds := []tea.Cmd{}
167 | if len(m.items) == 0 {
168 | m.loading.SetActive(true)
169 | cmds = append(cmds, m.loading.Init())
170 | }
171 |
172 | if m.req != nil {
173 | cmds = append(cmds, m.SetDefaultParams(), getFeedCmd(m.app.client, m.req))
174 | } else if m.feedType == feedTypeFollowing {
175 | cmds = append(cmds, getDefaultFeedCmd(m.app.client, m.app.ctx.signer))
176 | }
177 | return tea.Sequence(cmds...)
178 | }
179 |
180 | func (m *FeedView) Clear() {
181 | m.loading.SetActive(true)
182 | m.items = nil
183 | m.req = nil
184 | m.table.SetRows([]table.Row{})
185 | m.setItems(nil)
186 | }
187 |
188 | func likeCastCmd(client *api.Client, signer *api.Signer, cast *api.Cast) tea.Cmd {
189 | return func() tea.Msg {
190 | log.Println("liking cast", cast.Hash)
191 | if err := client.React(signer, cast.Hash, "like"); err != nil {
192 | return apiErrorMsg{err}
193 | }
194 | return reactMsg{hash: cast.Hash, rtype: "like", state: true}
195 | }
196 | }
197 |
198 | func getDefaultFeedCmd(client *api.Client, signer *api.Signer) tea.Cmd {
199 | if signer == nil {
200 | return nil
201 | }
202 | req := &api.FeedRequest{Limit: 100}
203 | req.FeedType = "following"
204 | req.FID = signer.FID
205 | req.ViewerFID = signer.FID
206 | return getFeedCmd(client, req)
207 | }
208 |
209 | func getFeedCmd(client *api.Client, req *api.FeedRequest) tea.Cmd {
210 | return func() tea.Msg {
211 | if req.Limit == 0 {
212 | req.Limit = 100
213 | }
214 | feed, err := client.GetFeed(req)
215 | if err != nil {
216 | log.Println("feedview error getting feed", err)
217 | return err
218 | }
219 | return feed
220 | }
221 | }
222 |
223 | func getChannelFeedCmd(client *api.Client, pu string) tea.Cmd {
224 | return func() tea.Msg {
225 | log.Println("getting channel feed")
226 | req := &api.FeedRequest{
227 | FeedType: "filter", FilterType: "parent_url",
228 | ParentURL: pu, Limit: 100,
229 | }
230 | feed, err := client.GetFeed(req)
231 | return &channelFeedMsg{feed.Casts, err}
232 | }
233 | }
234 |
235 | func (m *FeedView) SetDefaultParams() tea.Cmd {
236 | var fid uint64
237 | if m.app.ctx.signer != nil {
238 | fid = m.app.ctx.signer.FID
239 | }
240 | return tea.Sequence(
241 | m.setItems(nil),
242 | getFeedCmd(m.app.client, &api.FeedRequest{
243 | FeedType: "following", Limit: 100,
244 | FID: fid, ViewerFID: fid,
245 | }),
246 | )
247 | }
248 | func (m *FeedView) SetParams(req *api.FeedRequest) tea.Cmd {
249 | return tea.Sequence(
250 | m.setItems(nil),
251 | getFeedCmd(m.app.client, req),
252 | )
253 | }
254 |
255 | func (m *FeedView) setItems(casts []*api.Cast) tea.Cmd {
256 | rows := []table.Row{}
257 | cmds := []tea.Cmd{}
258 | for _, cast := range casts {
259 | ci, cmd := NewCastFeedItem(m.app, cast, true)
260 | m.items = append(m.items, ci)
261 | if cmd != nil {
262 | cmds = append(cmds, cmd)
263 | }
264 | rows = append(rows, ci.AsRow(m.showChannel, m.showStats))
265 | }
266 | m.table.SetRows(rows)
267 | m.loading.SetActive(false)
268 |
269 | done := func() tea.Msg {
270 | return &feedLoadedMsg{}
271 | }
272 | cmds = append(cmds, done)
273 |
274 | return tea.Batch(cmds...)
275 | }
276 |
277 | func (m *FeedView) populateItems() tea.Cmd {
278 | rows := []table.Row{}
279 | for _, i := range m.items {
280 | rows = append(rows, i.AsRow(m.showChannel, m.showStats))
281 | }
282 |
283 | if len(rows) > 0 {
284 | m.loading.SetActive(false)
285 | }
286 |
287 | m.table.SetRows(rows)
288 | return nil
289 | }
290 |
291 | func selectCast(cast *api.Cast) tea.Cmd {
292 | return func() tea.Msg {
293 | return SelectCastMsg{cast: cast}
294 | }
295 | }
296 |
297 | func (m *FeedView) getCurrentItem() *CastFeedItem {
298 | row := m.table.Cursor()
299 | if row < 0 || row >= len(m.items) {
300 | return nil
301 | }
302 | return m.items[row]
303 | }
304 |
305 | func (m *FeedView) hideDescription() {
306 | m.descVp.Width = 0
307 | m.descVp.Height = 0
308 | m.headerImg.SetSize(0, 0)
309 |
310 | }
311 |
312 | func (m *FeedView) SetSize(w, h int) {
313 | m.w, m.h = w, h
314 |
315 | m.hideDescription()
316 | if m.description != "" {
317 | m.descVp.SetContent(m.description)
318 | dmin := 8
319 | dPct := int(float64(h) * 0.2)
320 | dy := dPct
321 | if dmin > dPct {
322 | dy = dmin
323 | }
324 | m.headerImg.SetSize(4, 4)
325 | fx, fy := channelHeaderStyle.GetFrameSize()
326 | m.descVp.Width = w - fx - 4
327 | m.descVp.Height = dy - fy
328 | }
329 |
330 | _, dy := lipgloss.Size(channelHeaderStyle.Render(m.descVp.View()))
331 | fx, fy := feedStyle.GetFrameSize()
332 | x := min(w-fx, int(float64(GetWidth())*0.75))
333 | m.table.SetWidth(x)
334 | m.table.SetHeight(h - fy - dy)
335 |
336 | // m.table.SetWidth(w -fx)
337 | m.setTableConfig()
338 |
339 | lw := int(float64(w) * 0.75)
340 | m.loading.SetSize(lw, h)
341 | }
342 |
343 | func (m *FeedView) SelectCurrentItem() tea.Cmd {
344 | current := m.getCurrentItem()
345 | if current == nil {
346 | return nil
347 | }
348 | return selectCast(current.cast)
349 | }
350 |
351 | func (m *FeedView) OpenCurrentItem() tea.Cmd {
352 | current := m.getCurrentItem()
353 | if current == nil {
354 | return nil
355 | }
356 | return OpenURL(fmt.Sprintf("https://warpcast.com/%s/%s", current.cast.Author.Username, current.cast.Hash))
357 | }
358 | func (m *FeedView) ViewCurrentProfile() tea.Cmd {
359 | current := m.getCurrentItem()
360 | if current == nil {
361 | return nil
362 | }
363 | userFid := current.cast.Author.FID
364 | m.loading.SetActive(true)
365 | return tea.Sequence(
366 | m.app.FocusProfile(),
367 | getUserCmd(m.app.client, userFid, m.app.ctx.signer.FID),
368 | getUserFeedCmd(m.app.client, userFid, m.app.ctx.signer.FID),
369 | )
370 | }
371 |
372 | func fetchChannelCmd(client *api.Client, pu string) tea.Cmd {
373 | return func() tea.Msg {
374 | log.Println("fetching channel obj")
375 | c, err := client.GetChannelByParentUrl(pu)
376 | return &fetchChannelMsg{pu, c, err}
377 | }
378 | }
379 |
380 | func (m *FeedView) ViewCurrentChannel() tea.Cmd {
381 | current := m.getCurrentItem()
382 | if current == nil {
383 | return nil
384 | }
385 | if current.cast.ParentURL == "" {
386 | return nil
387 | }
388 | if m.feedType == feedTypeChannel {
389 | log.Println("already viewing channel")
390 | return nil
391 | }
392 | m.loading.SetActive(true)
393 | return tea.Batch(
394 | getChannelFeedCmd(m.app.client, current.cast.ParentURL),
395 | fetchChannelCmd(m.app.client, current.cast.ParentURL),
396 | m.app.FocusChannel(),
397 | )
398 | }
399 |
400 | func (m *FeedView) LikeCurrentItem() tea.Cmd {
401 | current := m.getCurrentItem()
402 | if current == nil {
403 | return nil
404 | }
405 | if current.cast.Hash == "" {
406 | return nil
407 | }
408 | return likeCastCmd(m.app.client, m.app.ctx.signer, current.cast)
409 | }
410 |
411 | func (m *FeedView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
412 | var cmds []tea.Cmd
413 | _, cmd := m.loading.Update(msg)
414 |
415 | cmds = append(cmds, cmd)
416 | switch msg := msg.(type) {
417 | case tea.KeyMsg:
418 | if cmd := FeedKeyMap.HandleMsg(m, msg); cmd != nil {
419 | return m, cmd
420 | }
421 |
422 | case loadTickMsg:
423 | _, cmd := m.loading.Update(msg)
424 | return m, cmd
425 |
426 | case *api.FeedResponse:
427 | return m, m.setItems(msg.Casts)
428 | case *channelFeedMsg:
429 | if msg.err != nil {
430 | log.Println("channel feed error", msg.err)
431 | return m, nil
432 | }
433 | m.Clear()
434 | return m, tea.Batch(m.setItems(msg.casts))
435 | case *profileFeedMsg:
436 | if m.feedType != feedTypeProfile {
437 | return m, nil
438 | }
439 | m.Clear()
440 | return m, m.setItems(msg.casts)
441 | case *fetchChannelMsg:
442 | if msg.err != nil {
443 | return m, nil
444 | }
445 | m.SetDescription(channelDescription(msg.channel, m.headerImg))
446 | m.headerImg.SetURL(msg.channel.ImageURL, false)
447 | return m, m.headerImg.Render()
448 |
449 | case reactMsg:
450 | current := m.getCurrentItem()
451 | if current == nil || current.cast == nil {
452 | return m, nil
453 | }
454 |
455 | if current.cast.Hash != msg.hash {
456 | return m, m.SetDefaultParams()
457 | }
458 | if msg.rtype == "like" && msg.state {
459 | current.cast.ViewerContext.Liked = true
460 | }
461 |
462 | case tea.WindowSizeMsg:
463 | m.SetSize(msg.Width, msg.Height)
464 | return m, nil
465 | }
466 |
467 | _, icmd := m.headerImg.Update(msg)
468 | cmds = append(cmds, icmd)
469 |
470 | newItems := []*CastFeedItem{}
471 | for _, c := range m.items {
472 | ni, cmd := c.Update(msg)
473 | ci, ok := ni.(*CastFeedItem)
474 | if !ok {
475 | log.Println("failed to cast to CastFeedItem")
476 | }
477 | newItems = append(newItems, ci)
478 |
479 | cmds = append(cmds, cmd)
480 | }
481 | m.items = newItems
482 |
483 | // update table with updated items
484 | m.populateItems()
485 |
486 | _, lcmd := m.loading.Update(msg)
487 | cmds = append(cmds, lcmd)
488 |
489 | t, cmd := m.table.Update(msg)
490 | cmds = append(cmds, cmd)
491 | m.table = t
492 | return m, tea.Batch(cmds...)
493 | }
494 |
495 | func (m *FeedView) View() string {
496 | if m.loading.IsActive() {
497 | return m.loading.View()
498 | }
499 | if m.feedType == feedTypeChannel {
500 | return lipgloss.JoinVertical(lipgloss.Top,
501 | channelHeaderStyle.Render(m.descVp.View()),
502 | feedStyle.Render(m.table.View()),
503 | )
504 | }
505 |
506 | return feedStyle.Render(m.table.View())
507 |
508 | }
509 |
510 | func channelStats(c *api.Channel, margin int) string {
511 | if c == nil {
512 | return spinner.New().View()
513 | }
514 | stats := lipgloss.JoinHorizontal(lipgloss.Top,
515 | NewStyle().Render(fmt.Sprintf("/%s ", c.ID)),
516 | NewStyle().MarginRight(margin).Render(EmojiPerson),
517 | NewStyle().Render(fmt.Sprintf("%d ", c.FollowerCount)),
518 | NewStyle().MarginRight(margin).Render("followers"),
519 | // NewStyle().Render(fmt.Sprintf("%d ", c.Object
520 | // NewStyle().MarginRight(margin).Render(EmojiRecyle),
521 | )
522 | return stats
523 | }
524 |
525 | func channelHeader(c *api.Channel, img *ImageModel) string {
526 | return headerStyle.Render(lipgloss.JoinHorizontal(lipgloss.Center,
527 | img.View(),
528 | lipgloss.JoinVertical(lipgloss.Top,
529 | displayNameStyle.Render(c.Name),
530 | c.Description,
531 | ),
532 | ),
533 | )
534 | }
535 |
536 | func channelDescription(c *api.Channel, img *ImageModel) string {
537 | return lipgloss.JoinVertical(lipgloss.Bottom,
538 | channelHeader(c, img),
539 | NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).Padding(0).Render(channelStats(c, 1)),
540 | )
541 | }
542 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
3 | github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
4 | github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
5 | github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
6 | github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
7 | github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
8 | github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
9 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
10 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
11 | github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
12 | github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
13 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
14 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
15 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
16 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
17 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
18 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
19 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
20 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
21 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
22 | github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
23 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
24 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
25 | github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY=
26 | github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc=
27 | github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
28 | github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
29 | github.com/charmbracelet/bubbletea v0.26.4 h1:2gDkkzLZaTjMl/dQBpNVtnvcCxsh/FCkimep7FC9c40=
30 | github.com/charmbracelet/bubbletea v0.26.4/go.mod h1:P+r+RRA5qtI1DOHNFn0otoNwB4rn+zNAzSj/EXz6xU0=
31 | github.com/charmbracelet/glamour v0.7.0 h1:2BtKGZ4iVJCDfMF229EzbeR1QRKLWztO9dMtjmqZSng=
32 | github.com/charmbracelet/glamour v0.7.0/go.mod h1:jUMh5MeihljJPQbJ/wf4ldw2+yBP59+ctV36jASy7ps=
33 | github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
34 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
35 | github.com/charmbracelet/keygen v0.5.0 h1:XY0fsoYiCSM9axkrU+2ziE6u6YjJulo/b9Dghnw6MZc=
36 | github.com/charmbracelet/keygen v0.5.0/go.mod h1:DfvCgLHxZ9rJxdK0DGw3C/LkV4SgdGbnliHcObV3L+8=
37 | github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g=
38 | github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8=
39 | github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
40 | github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
41 | github.com/charmbracelet/ssh v0.0.0-20240401141849-854cddfa2917 h1:NZKjJ7d/pzk/AfcJYEzmF8M48JlIrrY00RR5JdDc3io=
42 | github.com/charmbracelet/ssh v0.0.0-20240401141849-854cddfa2917/go.mod h1:8/Ve8iGRRIGFM1kepYfRF2pEOF5Y3TEZYoJaA54228U=
43 | github.com/charmbracelet/wish v1.4.0 h1:pL1uVP/YuYgJheHEj98teZ/n6pMYnmlZq/fcHvomrfc=
44 | github.com/charmbracelet/wish v1.4.0/go.mod h1:ew4/MjJVfW/akEO9KmrQHQv1F7bQRGscRMrA+KtovTk=
45 | github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY=
46 | github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
47 | github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651 h1:3RXpZWGWTOeVXCTv0Dnzxdv/MhNUkBfEcbaTY0zrTQI=
48 | github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
49 | github.com/charmbracelet/x/exp/term v0.0.0-20240328150354-ab9afc214dfd h1:HqBjkSFXXfW4IgX3TMKipWoPEN08T3Pi4SA/3DLss/U=
50 | github.com/charmbracelet/x/exp/term v0.0.0-20240328150354-ab9afc214dfd/go.mod h1:6GZ13FjIP6eOCqWU4lqgveGnYxQo9c3qBzHPeFu4HBE=
51 | github.com/charmbracelet/x/input v0.1.1 h1:YDOJaTUKCqtGnq9PHzx3pkkl4pXDOANUHmhH3DqMtM4=
52 | github.com/charmbracelet/x/input v0.1.1/go.mod h1:jvdTVUnNWj/RD6hjC4FsoB0SeZCJ2ZBkiuFP9zXvZI0=
53 | github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
54 | github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
55 | github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg=
56 | github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
57 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
58 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
59 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
60 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
61 | github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
62 | github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
63 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
64 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
65 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
66 | github.com/dgraph-io/badger/v4 v4.2.0 h1:kJrlajbXXL9DFTNuhhu9yCx7JJa4qpYWxtE8BzuWsEs=
67 | github.com/dgraph-io/badger/v4 v4.2.0/go.mod h1:qfCqhPoWDFJRx1gp5QwwyGo8xk1lbHUxvK9nK0OGAak=
68 | github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
69 | github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
70 | github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
71 | github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
72 | github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
73 | github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
74 | github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
75 | github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
76 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
77 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
78 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
79 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
80 | github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
81 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
82 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
83 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
84 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
85 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
86 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
87 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
88 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
89 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
90 | github.com/golang/glog v1.2.1 h1:OptwRhECazUx5ix5TTWC3EZhsZEHWcYWY4FQHTIubm4=
91 | github.com/golang/glog v1.2.1/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
92 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
93 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
94 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
95 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
96 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
97 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
98 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
99 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
100 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
101 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
102 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
103 | github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
104 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
105 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
106 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
107 | github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
108 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
109 | github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
110 | github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
111 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
112 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
113 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
114 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
115 | github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
116 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
117 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
118 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
119 | github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
120 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
121 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
122 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
123 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
124 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
125 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
126 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
127 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
128 | github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
129 | github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
130 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
131 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
132 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
133 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
134 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
135 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
136 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
137 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
138 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
139 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
140 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
141 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
142 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
143 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
144 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
145 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
146 | github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
147 | github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
148 | github.com/mistakenelf/teacup v0.4.1 h1:QPNyIqrNKeizeGZc9cE6n+nAsIBu52oUf3bCkfGyBwk=
149 | github.com/mistakenelf/teacup v0.4.1/go.mod h1:8v/aIRCfrae6Uit1WFPHv0xzwi1XELZkAHiTybNSZTk=
150 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
151 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
152 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
153 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
154 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
155 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
156 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
157 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
158 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
159 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
160 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
161 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
162 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
163 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
164 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
165 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
166 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
167 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
168 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
169 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
170 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
171 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
172 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
173 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
174 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
175 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
176 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
177 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
178 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
179 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
180 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
181 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
182 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
183 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
184 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
185 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
186 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
187 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
188 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
189 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
190 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
191 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
192 | github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
193 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
194 | github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
195 | github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
196 | github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s=
197 | github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY=
198 | go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
199 | go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
200 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
201 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
202 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
203 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
204 | golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
205 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
206 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
207 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
208 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
209 | golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
210 | golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw=
211 | golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
212 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
213 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
214 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
215 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
216 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
217 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
218 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
219 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
220 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
221 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
222 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
223 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
224 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
225 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
226 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
227 | golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
228 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
229 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
230 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
231 | golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
232 | golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
233 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
234 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
235 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
236 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
237 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
238 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
239 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
240 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
241 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
242 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
243 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
244 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
245 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
246 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
247 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
248 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
249 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
250 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
251 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
252 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
253 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
254 | golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
255 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
256 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
257 | golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
258 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
259 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
260 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
261 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
262 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
263 | golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
264 | golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
265 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
266 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
267 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
268 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
269 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
270 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
271 | golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
272 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
273 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
274 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
275 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
276 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
277 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
278 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
279 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
280 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
281 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
282 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
283 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
284 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
285 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
286 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
287 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
288 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
289 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
290 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
291 | google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
292 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
293 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
294 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
295 | google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
296 | google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
297 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
298 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
299 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
300 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
301 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
302 | google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
303 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
304 | google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
305 | google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
306 | google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
307 | google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
308 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
309 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
310 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
311 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
312 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
313 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
314 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
315 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
316 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
317 |
--------------------------------------------------------------------------------