ayn2op 3 лет назад
Родитель
Сommit
8a96df64c5
24 измененных файлов с 1045 добавлено и 1662 удалено
  1. 4 0
      .github/workflows/ci.yml
  2. 2 8
      README.md
  3. 161 0
      config.go
  4. 0 139
      config/config.go
  5. 12 14
      discordmd/discordmd.go
  6. 22 0
      discordmd/discordmd_test.go
  7. 8 0
      go.mod
  8. 0 77
      go.sum
  9. 183 0
      guilds_tree.go
  10. 81 0
      login_form.go
  11. 70 14
      main.go
  12. 137 0
      message_input.go
  13. 289 0
      messages_text.go
  14. 76 0
      state.go
  15. 0 233
      ui/actions_list.go
  16. 0 203
      ui/application.go
  17. 0 231
      ui/builder.go
  18. 0 163
      ui/channels_tree.go
  19. 0 63
      ui/guilds_tree.go
  20. 0 75
      ui/login_form.go
  21. 0 160
      ui/message_input.go
  22. 0 182
      ui/messages_text.go
  23. 0 58
      ui/util.go
  24. 0 42
      ui/view.go

+ 4 - 0
.github/workflows/ci.yml

@@ -16,7 +16,11 @@ jobs:
           go-version: '^1.19'
 
       - name: Build
+<<<<<<< HEAD
         run: make
+=======
+        run: go build .
+>>>>>>> rewrite
   
       - uses: actions/upload-artifact@v3
         if: ${{ github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}

+ 2 - 8
README.md

@@ -51,14 +51,8 @@ sudo mv ./discordo /usr/local/bin
 
 ### Linux clipboard support
 
-- `xclip` or `xsel` for X11.
-  - Ubuntu: `apt install xclip`
-  - Arch Linux: `pacman -S xclip`
-  - Fedora: `dnf install xclip`
-- `wl-clipboard` for Wayland.
-  - Ubuntu: `apt install wl-clipboard`
-  - Arch Linux: `pacman -S wl-clipboard`
-  - Fedora: `dnf install wl-clipboard`
+- `xclip` or `xsel` for X11 (`apt install xclip`)
+- `wl-clipboard` for Wayland (`apt install wl-clipboard`)
 
 ## Usage
 

+ 161 - 0
config.go

@@ -0,0 +1,161 @@
+package main
+
+import (
+	"os"
+	"path/filepath"
+
+	"gopkg.in/yaml.v3"
+)
+
+const name = "discordo"
+
+type (
+	MessagesTextKeysConfig struct {
+		CopyContent string `yaml:"copy_content"`
+
+		Reply        string `yaml:"reply"`
+		ReplyMention string `yaml:"reply_mention"`
+		SelectReply  string `yaml:"select_reply"`
+
+		SelectPrevious string `yaml:"select_previous"`
+		SelectNext     string `yaml:"select_next"`
+		SelectFirst    string `yaml:"select_first"`
+		SelectLast     string `yaml:"select_last"`
+	}
+
+	MessageInputKeysConfig struct {
+		Send  string `yaml:"send"`
+		Paste string `yaml:"paste"`
+
+		LaunchEditor string `yaml:"launch_editor"`
+	}
+
+	KeysConfig struct {
+		Cancel string `yaml:"cancel"`
+
+		MessagesText MessagesTextKeysConfig `yaml:"messages_text"`
+		MessageInput MessageInputKeysConfig `yaml:"message_input"`
+	}
+)
+
+type (
+	GuildsTreeThemeConfig struct {
+		Graphics bool `yaml:"graphics"`
+	}
+
+	MessagesTextThemeConfig struct {
+		AuthorColor string `yaml:"author_color"`
+	}
+
+	MessageInputThemeConfig struct{}
+
+	ThemeConfig struct {
+		Border        bool   `yaml:"border"`
+		BorderColor   string `yaml:"border_color"`
+		BorderPadding [4]int `yaml:"border_padding,flow"`
+
+		TitleColor      string `yaml:"title_color"`
+		BackgroundColor string `yaml:"background_color"`
+
+		GuildsTree   GuildsTreeThemeConfig   `yaml:"guilds_tree"`
+		MessagesText MessagesTextThemeConfig `yaml:"messages_text"`
+		MessageInput MessageInputThemeConfig `yaml:"message_input"`
+	}
+)
+
+type Config struct {
+	Mouse         bool   `yaml:"mouse"`
+	MessagesLimit uint   `yaml:"messages_limit"`
+	Timestamps    bool   `yaml:"timestamps"`
+	Editor        string `yaml:"editor"`
+
+	Keys  KeysConfig  `yaml:"keys"`
+	Theme ThemeConfig `yaml:"theme"`
+}
+
+func newConfig() (*Config, error) {
+	path, err := os.UserConfigDir()
+	if err != nil {
+		return nil, err
+	}
+
+	path = filepath.Join(path, name)
+	err = os.MkdirAll(path, os.ModePerm)
+	if err != nil {
+		return nil, err
+	}
+
+	c := Config{
+		Mouse:         true,
+		Timestamps:    false,
+		MessagesLimit: 50,
+		Editor:        "default",
+
+		Keys: KeysConfig{
+			Cancel: "Esc",
+
+			MessagesText: MessagesTextKeysConfig{
+				CopyContent: "Rune[c]",
+
+				Reply:        "Rune[r]",
+				ReplyMention: "Rune[R]",
+				SelectReply:  "Rune[s]",
+
+				SelectPrevious: "Up",
+				SelectNext:     "Down",
+				SelectFirst:    "Home",
+				SelectLast:     "End",
+			},
+			MessageInput: MessageInputKeysConfig{
+				Send: "Enter",
+
+				Paste:        "Ctrl+V",
+				LaunchEditor: "Ctrl+E",
+			},
+		},
+
+		Theme: ThemeConfig{
+			Border:        true,
+			BorderColor:   "default",
+			BorderPadding: [...]int{0, 0, 1, 1},
+
+			TitleColor:      "default",
+			BackgroundColor: "default",
+
+			GuildsTree: GuildsTreeThemeConfig{
+				Graphics: true,
+			},
+			MessagesText: MessagesTextThemeConfig{
+				AuthorColor: "aqua",
+			},
+			MessageInput: MessageInputThemeConfig{},
+		},
+	}
+	path = filepath.Join(path, "config.yml")
+	_, err = os.Stat(path)
+	if os.IsNotExist(err) {
+		f, err := os.Create(path)
+		if err != nil {
+			return nil, err
+		}
+		defer f.Close()
+
+		err = yaml.NewEncoder(f).Encode(c)
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		f, err := os.Open(path)
+		if err != nil {
+			return nil, err
+		}
+		defer f.Close()
+
+		err = yaml.NewDecoder(f).Decode(&c)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return &c, nil
+}

+ 0 - 139
config/config.go

@@ -1,139 +0,0 @@
-package config
-
-import (
-	_ "embed"
-	"os"
-	"path/filepath"
-	"time"
-
-	"gopkg.in/yaml.v3"
-)
-
-const Name = "discordo"
-
-type (
-	MessagesTextKeysConfig struct {
-		LaunchActions string `yaml:"launch_actions"`
-
-		SelectPrevious string `yaml:"select_previous"`
-		SelectNext     string `yaml:"select_next"`
-		SelectFirst    string `yaml:"select_first"`
-		SelectLast     string `yaml:"select_last"`
-	}
-
-	MessageInputKeysConfig struct {
-		Send  string `yaml:"send"`
-		Paste string `yaml:"paste"`
-
-		LaunchEditor string `yaml:"launch_editor"`
-	}
-
-	KeysConfig struct {
-		MessagesText MessagesTextKeysConfig `yaml:"messages_text"`
-		MessageInput MessageInputKeysConfig `yaml:"message_input"`
-	}
-)
-
-type ThemeConfig struct {
-	Background string `yaml:"background"`
-	Border     string `yaml:"border"`
-	Title      string `yaml:"title"`
-}
-
-type Config struct {
-	// Whether the mouse is usable or not.
-	Mouse bool `yaml:"mouse"`
-	// The maximum number of messages to fetch and display. Its value must not be lesser than 1 and greater than 100.
-	MessagesLimit uint `yaml:"messages_limit"`
-	// Whether to display the timestamps of the messages beside the displayed message or not.
-	Timestamps bool `yaml:"timestamps"`
-	// The timezone of the timestamps. Learn more: https://pkg.go.dev/time#LoadLocation
-	Timezone string `yaml:"timezone"`
-	// A textual representation of the time value formatted according to the layout defined by its value. Learn more: https://pkg.go.dev/time#Layout
-	TimeFormat string `yaml:"time_format"`
-
-	Keys  KeysConfig  `yaml:"keys"`
-	Theme ThemeConfig `yaml:"theme"`
-}
-
-func New() (*Config, error) {
-	path, err := os.UserConfigDir()
-	if err != nil {
-		return nil, err
-	}
-
-	// Create the configuration directory if it does not exist already.
-	path = filepath.Join(path, Name)
-	err = os.MkdirAll(path, os.ModePerm)
-	if err != nil {
-		return nil, err
-	}
-
-	c := def()
-	path = filepath.Join(path, "config.yml")
-	_, err = os.Stat(path)
-	// If the configuration file does not exist, create a new one and write the default configuration to it.
-	if os.IsNotExist(err) {
-		f, err := os.Create(path)
-		if err != nil {
-			return nil, err
-		}
-		defer f.Close()
-
-		err = yaml.NewEncoder(f).Encode(c)
-		if err != nil {
-			return nil, err
-		}
-	} else {
-		f, err := os.Open(path)
-		if err != nil {
-			return nil, err
-		}
-		defer f.Close()
-
-		err = yaml.NewDecoder(f).Decode(&c)
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	return &c, nil
-}
-
-func def() Config {
-	return Config{
-		Mouse:         true,
-		MessagesLimit: 50,
-		Timestamps:    false,
-		Timezone:      "Local",
-		TimeFormat:    time.Kitchen,
-
-		Keys: KeysConfig{
-			MessagesText: MessagesTextKeysConfig{
-				LaunchActions: "Rune[a]",
-
-				SelectPrevious: "Up",
-				SelectNext:     "Down",
-				SelectFirst:    "Home",
-				SelectLast:     "End",
-			},
-			MessageInput: MessageInputKeysConfig{
-				Send:  "Enter",
-				Paste: "Ctrl+V",
-
-				LaunchEditor: "Ctrl+E",
-			},
-		},
-		Theme: ThemeConfig{
-			Background: "default",
-			Border:     "white",
-			Title:      "white",
-		},
-	}
-}
-
-// LogDirPath returns the path of the log directory.
-func LogDirPath() string {
-	path, _ := os.UserCacheDir()
-	return filepath.Join(path, Name)
-}

+ 12 - 14
discordmd/discordmd.go

@@ -5,20 +5,18 @@ import (
 )
 
 var (
-	boldRegex          = regexp.MustCompile(`(?ms)\*\*(.*?)\*\*`)
-	italicRegex        = regexp.MustCompile(`(?ms)\*(.*?)\*`)
-	underlineRegex     = regexp.MustCompile(`(?ms)__(.*?)__`)
-	strikeThroughRegex = regexp.MustCompile(`(?ms)~~(.*?)~~`)
+	boldRegex            = regexp.MustCompile(`(?ms)\*\*(.*?)\*\*`)
+	italicRegex          = regexp.MustCompile(`(?ms)\*(.*?)\*`)
+	underlineRegex       = regexp.MustCompile(`(?ms)__(.*?)__`)
+	strikeThroughRegex   = regexp.MustCompile(`(?ms)~~(.*?)~~`)
+	inlineCodeBlockRegex = regexp.MustCompile("(?ms)`" + `([^` + "`" + `\n]+)` + "`")
 )
 
-// Parse parses Discord-flavored markdown to tview's [Color Tags].
-//
-// [Color Tags]: https://pkg.go.dev/github.com/rivo/tview#hdr-Colors
-func Parse(md string) string {
-	md = boldRegex.ReplaceAllString(md, "[::b]$1[::-]")
-	md = italicRegex.ReplaceAllString(md, "[::i]$1[::-]")
-	md = underlineRegex.ReplaceAllString(md, "[::u]$1[::-]")
-	md = strikeThroughRegex.ReplaceAllString(md, "[::s]$1[::-]")
-
-	return md
+func Parse(input string) string {
+	input = boldRegex.ReplaceAllString(input, "[::b]$1[::-]")
+	input = italicRegex.ReplaceAllString(input, "[::i]$1[::-]")
+	input = underlineRegex.ReplaceAllString(input, "[::u]$1[::-]")
+	input = strikeThroughRegex.ReplaceAllString(input, "[::s]$1[::-]")
+	input = inlineCodeBlockRegex.ReplaceAllString(input, "[::r]$1[::-]")
+	return input
 }

+ 22 - 0
discordmd/discordmd_test.go

@@ -0,0 +1,22 @@
+package discordmd
+
+import (
+	"testing"
+)
+
+const input = `**Lorem** ipsum dolor sit amet, consectetur adipiscing __elit.__ Nullam ante magna, luctus in ~~molestie non, elementum sit~~ amet tortor. Nunc euismod urna ac massa dictum ultrices. Donec tempor __dignissim__ ullamcorper. Mauris ultricies, risus non malesuada consectetur, *purus leo interdum purus*, nec vestibulum lacus neque non nulla.`
+
+func TestParse(t *testing.T) {
+	const want = `[::b]Lorem[::-] ipsum dolor sit amet, consectetur adipiscing [::u]elit.[::-] Nullam ante magna, luctus in [::s]molestie non, elementum sit[::-] amet tortor. Nunc euismod urna ac massa dictum ultrices. Donec tempor [::u]dignissim[::-] ullamcorper. Mauris ultricies, risus non malesuada consectetur, [::i]purus leo interdum purus[::-], nec vestibulum lacus neque non nulla.`
+
+	got := Parse(input)
+	if got != want {
+		t.Errorf("got %s; want %s", got, want)
+	}
+}
+
+func BenchmarkParse(b *testing.B) {
+	for i := 0; i < b.N; i++ {
+		_ = Parse(input)
+	}
+}

+ 8 - 0
go.mod

@@ -9,6 +9,11 @@ require (
 	github.com/rivo/tview v0.0.0-20221221172820-02e38ea9604c
 	github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
 	github.com/zalando/go-keyring v0.2.1
+	github.com/diamondburned/arikawa v1.3.14
+	github.com/diamondburned/arikawa/v3 v3.2.0
+	github.com/gdamore/tcell/v2 v2.5.4
+	github.com/rivo/tview v0.0.0-20230104153304-892d1a2eb0da
+	github.com/zalando/go-keyring v0.2.2
 	gopkg.in/yaml.v3 v3.0.1
 )
 
@@ -26,5 +31,8 @@ require (
 	golang.org/x/sys v0.3.0 // indirect
 	golang.org/x/term v0.3.0 // indirect
 	golang.org/x/text v0.5.0 // indirect
+	golang.org/x/sys v0.4.0 // indirect
+	golang.org/x/term v0.4.0 // indirect
+	golang.org/x/text v0.6.0 // indirect
 	golang.org/x/time v0.3.0 // indirect
 )

+ 0 - 77
go.sum

@@ -1,77 +0,0 @@
-github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
-github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
-github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
-github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7hqDjlFjiygg=
-github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
-github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
-github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/diamondburned/arikawa/v3 v3.1.0 h1:M7ZyjoCM4o1+rzai2NMXjOSukSrPdP6OxV68WKvKZ6E=
-github.com/diamondburned/arikawa/v3 v3.1.0/go.mod h1:5jBSNnp82Z/EhsKa6Wk9FsOqSxfVkNZDTDBPOj47LpY=
-github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
-github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
-github.com/gdamore/tcell/v2 v2.5.3 h1:b9XQrT6QGbgI7JvZOJXFNczOQeIYbo8BfeSMzt2sAV0=
-github.com/gdamore/tcell/v2 v2.5.3/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo=
-github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
-github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
-github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
-github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
-github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
-github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
-github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rivo/tview v0.0.0-20221217182043-ccce554c3803 h1:gaknGRzW4g4I+5sGu4X81BZbROJ0j96ap9xnEbcZhXA=
-github.com/rivo/tview v0.0.0-20221217182043-ccce554c3803/go.mod h1:YX2wUZOcJGOIycErz2s9KvDaP0jnWwRCirQMPLPpQ+Y=
-github.com/rivo/tview v0.0.0-20221221172820-02e38ea9604c h1:Y4GSXEYKYAtguH10lmQmYb7hRkJ7U+m8GvnFHKU2jrk=
-github.com/rivo/tview v0.0.0-20221221172820-02e38ea9604c/go.mod h1:lBUy/T5kyMudFzWUH/C2moN+NlU5qF505vzOyINXuUQ=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
-github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
-github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
-github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
-github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/zalando/go-keyring v0.2.1 h1:MBRN/Z8H4U5wEKXiD67YbDAr5cj/DOStmSga70/2qKc=
-github.com/zalando/go-keyring v0.2.1/go.mod h1:g63M2PPn0w5vjmEbwAX3ib5I+41zdm4esSETOn9Y6Dw=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211001092434-39dca1131b70/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
-golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
-golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
-golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
-golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 183 - 0
guilds_tree.go

@@ -0,0 +1,183 @@
+package main
+
+import (
+	"log"
+	"sort"
+	"strings"
+
+	"github.com/diamondburned/arikawa/v3/discord"
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+)
+
+type GuildsTree struct {
+	*tview.TreeView
+
+	root            *tview.TreeNode
+	selectedChannel *discord.Channel
+}
+
+func newGuildsTree() *GuildsTree {
+	gt := &GuildsTree{
+		TreeView: tview.NewTreeView(),
+
+		root: tview.NewTreeNode(""),
+	}
+
+	gt.SetGraphics(config.Theme.GuildsTree.Graphics)
+	gt.SetRoot(gt.root)
+	gt.SetTopLevel(1)
+	gt.SetSelectedFunc(gt.onSelected)
+
+	gt.SetBackgroundColor(tcell.GetColor(config.Theme.BackgroundColor))
+
+	gt.SetTitle("Guilds")
+	gt.SetTitleColor(tcell.GetColor(config.Theme.TitleColor))
+	gt.SetTitleAlign(tview.AlignLeft)
+
+	p := config.Theme.BorderPadding
+	gt.SetBorder(config.Theme.Border)
+	gt.SetBorderColor(tcell.GetColor(config.Theme.BorderColor))
+	gt.SetBorderPadding(p[0], p[1], p[2], p[3])
+
+	return gt
+}
+
+func (gt *GuildsTree) createGuildNodeFromID(n *tview.TreeNode, gid discord.GuildID) error {
+	g, err := discordState.Cabinet.Guild(gid)
+	if err != nil {
+		return err
+	}
+
+	gn := tview.NewTreeNode(g.Name)
+	gn.SetReference(g.ID)
+	n.AddChild(gn)
+	return nil
+}
+
+func (gt *GuildsTree) createChannelNode(n *tview.TreeNode, c discord.Channel) {
+	cn := tview.NewTreeNode(gt.channelToString(c))
+	cn.SetReference(c.ID)
+	n.AddChild(cn)
+}
+
+func (gt *GuildsTree) channelToString(c discord.Channel) string {
+	var s string
+	switch c.Type {
+	case discord.GuildText:
+		s = "#" + c.Name
+	case discord.DirectMessage:
+		r := c.DMRecipients[0]
+		s = r.Tag()
+	case discord.GuildVoice:
+		s = "v-" + c.Name
+	case discord.GroupDM:
+		s = c.Name
+		// If the name of the channel is empty, use the recipients' tags
+		if s == "" {
+			rs := make([]string, len(c.DMRecipients))
+			for _, r := range c.DMRecipients {
+				rs = append(rs, r.Tag())
+			}
+
+			s = strings.Join(rs, ", ")
+		}
+	case discord.GuildNews:
+		s = "n-" + c.Name
+	case discord.GuildStore:
+		s = "s-" + c.Name
+	default:
+		s = c.Name
+	}
+
+	return s
+}
+
+func (gt *GuildsTree) createChannelNodes(n *tview.TreeNode, cs []discord.Channel) {
+	for _, c := range cs {
+		// If the type of the channel is guild category or if the channel is an orphan channel.
+		if c.Type == discord.GuildCategory || !c.ParentID.IsValid() {
+			gt.createChannelNode(n, c)
+		} else {
+			var parent *tview.TreeNode
+			n.Walk(func(node, _ *tview.TreeNode) bool {
+				if node.GetReference() == c.ParentID {
+					parent = node
+					return false
+				}
+
+				return true
+			})
+
+			if parent != nil {
+				gt.createChannelNode(parent, c)
+			}
+		}
+	}
+}
+
+func (gt *GuildsTree) onSelected(n *tview.TreeNode) {
+	gt.selectedChannel = nil
+
+	messagesText.reset()
+	messageInput.reset()
+
+	if len(n.GetChildren()) != 0 {
+		n.SetExpanded(!n.IsExpanded())
+		return
+	}
+
+	switch ref := n.GetReference().(type) {
+	case discord.GuildID:
+		cs, err := discordState.Cabinet.Channels(ref)
+		if err != nil {
+			log.Println(err)
+			return
+		}
+
+		sort.Slice(cs, func(i, j int) bool {
+			return cs[i].Position < cs[j].Position
+		})
+
+		gt.createChannelNodes(n, cs)
+	case discord.ChannelID:
+		c, err := discordState.Cabinet.Channel(ref)
+		if err != nil {
+			log.Println(err)
+			return
+		}
+
+		gt.selectedChannel = c
+		messagesText.SetTitle(gt.channelToString(*c))
+
+		ms, err := discordState.Messages(ref, config.MessagesLimit)
+		if err != nil {
+			log.Println(err)
+			return
+		}
+
+		for i := len(ms) - 1; i >= 0; i-- {
+			err = messagesText.createMessage(&ms[i])
+			if err != nil {
+				log.Println(err)
+				continue
+			}
+		}
+
+		app.SetFocus(messageInput)
+	case nil: // Direct messages
+		cs, err := discordState.Cabinet.PrivateChannels()
+		if err != nil {
+			log.Println(err)
+			return
+		}
+
+		sort.Slice(cs, func(i, j int) bool {
+			return cs[i].LastMessageID < cs[j].LastMessageID
+		})
+
+		for _, c := range cs {
+			gt.createChannelNode(n, c)
+		}
+	}
+}

+ 81 - 0
login_form.go

@@ -0,0 +1,81 @@
+package main
+
+import (
+	"context"
+	"log"
+
+	"github.com/diamondburned/arikawa/api"
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+)
+
+type LoginForm struct {
+	*tview.Form
+}
+
+func newLoginForm() *LoginForm {
+	lf := &LoginForm{
+		Form: tview.NewForm(),
+	}
+
+	lf.AddInputField("Email", "", 0, nil, nil)
+	lf.AddPasswordField("Password", "", 0, 0, nil)
+	lf.AddPasswordField("Code (optional)", "", 0, 0, nil)
+	lf.AddButton("Login", lf.onLoginButtonSelected)
+
+	lf.SetTitle("Login")
+	lf.SetTitleColor(tcell.GetColor(config.Theme.TitleColor))
+
+	p := config.Theme.BorderPadding
+	lf.SetBorder(config.Theme.Border)
+	lf.SetBorderColor(tcell.GetColor(config.Theme.BorderColor))
+	lf.SetBorderPadding(p[0], p[1], p[2], p[3])
+
+	return lf
+}
+
+func (lf *LoginForm) onLoginButtonSelected() {
+	email := lf.GetFormItem(0).(*tview.InputField).GetText()
+	password := lf.GetFormItem(1).(*tview.InputField).GetText()
+	if email == "" || password == "" {
+		return
+	}
+
+	// Make a scratch HTTP client without a token
+	client := api.NewClient("").WithContext(context.Background())
+	// Try to login without TOTP
+	l, err := client.Login(email, password)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	// Retry logging in with a 2FA token
+	if l.Token == "" && l.MFA {
+		code := lf.GetFormItem(2).(*tview.InputField).GetText()
+		if code == "" {
+			return
+		}
+
+		l, err = client.TOTP(code, l.Ticket)
+		if err != nil {
+			log.Fatal(err)
+		}
+	}
+
+	// We got the token, return with a new Session.
+	discordState = newState(l.Token)
+	err = discordState.Open(context.Background())
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	right := tview.NewFlex()
+	right.SetDirection(tview.FlexRow)
+	right.AddItem(messagesText, 0, 1, false)
+	right.AddItem(messageInput, 3, 1, false)
+	// The guilds tree is always focused first at start-up.
+	flex.AddItem(guildsTree, 0, 1, true)
+	flex.AddItem(right, 0, 4, false)
+
+	app.SetRoot(flex, true)
+}

+ 70 - 14
main.go

@@ -1,13 +1,13 @@
 package main
 
 import (
+	"context"
 	"flag"
 	"log"
 	"os"
 	"path/filepath"
 
 	"github.com/ayn2op/discordo/config"
-	"github.com/ayn2op/discordo/ui"
 	"github.com/zalando/go-keyring"
 )
 
@@ -18,15 +18,40 @@ func init() {
 
 	path := config.LogDirPath()
 	err := os.MkdirAll(path, os.ModePerm)
+	"github.com/rivo/tview"
+	"github.com/zalando/go-keyring"
+)
+
+var (
+	token string
+
+	config       *Config
+	discordState *State
+
+	app  = tview.NewApplication()
+	flex = tview.NewFlex()
+
+	guildsTree   *GuildsTree
+	messagesText *MessagesText
+	messageInput *MessageInput
+)
+
+func init() {
+	flag.StringVar(&token, "token", "", "The authentication token.")
+
+	path, err := os.UserCacheDir()
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	path = filepath.Join(path, name)
+	err = os.MkdirAll(path, os.ModePerm)
 	if err != nil {
 		log.Fatal(err)
 	}
 
-	f, err := os.OpenFile(
-		filepath.Join(path, "logs.txt"),
-		os.O_CREATE|os.O_WRONLY,
-		os.ModePerm,
-	)
+	path = filepath.Join(path, "logs.txt")
+	f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, os.ModePerm)
 	if err != nil {
 		log.Fatal(err)
 	}
@@ -38,21 +63,52 @@ func init() {
 func main() {
 	flag.Parse()
 
-	cfg, err := config.New()
+	var err error
+	// If the token is passed via the flag, set it in the keyring.
+	if token != "" {
+		go keyring.Set(name, "token", token)
+	} else {
+		token, err = keyring.Get(name, "token")
+		if err != nil {
+			log.Println(err)
+			return
+		}
+	}
+
+	config, err = newConfig()
 	if err != nil {
 		log.Fatal(err)
 	}
 
-	if tokenFlag != "" {
-		go keyring.Set(config.Name, "token", tokenFlag)
+	// Initialize UI
+	guildsTree = newGuildsTree()
+	messagesText = newMessagesText()
+	messageInput = newMessageInput()
+
+	// mission failed, we'll get 'em next time
+	if token == "" {
+		app.SetRoot(newLoginForm(), true)
 	} else {
-		var err error
-		tokenFlag, err = keyring.Get(config.Name, "token")
+		discordState = newState(token)
+		err = discordState.Open(context.Background())
 		if err != nil {
-			log.Println(err)
+			log.Fatal(err)
 		}
+
+		right := tview.NewFlex()
+		right.SetDirection(tview.FlexRow)
+		right.AddItem(messagesText, 0, 1, false)
+		right.AddItem(messageInput, 3, 1, false)
+		// The guilds tree is always focused first at start-up.
+		flex.AddItem(guildsTree, 0, 1, true)
+		flex.AddItem(right, 0, 4, false)
+
+		app.SetRoot(flex, true)
 	}
 
-	app := ui.NewApplication(cfg)
-	app.Run(tokenFlag)
+	app.EnableMouse(config.Mouse)
+	err = app.Run()
+	if err != nil {
+		log.Fatal(err)
+	}
 }

+ 137 - 0
message_input.go

@@ -0,0 +1,137 @@
+package main
+
+import (
+	"log"
+	"os"
+	"os/exec"
+	"strings"
+
+	"github.com/atotto/clipboard"
+	"github.com/diamondburned/arikawa/v3/api"
+	"github.com/diamondburned/arikawa/v3/discord"
+	"github.com/diamondburned/arikawa/v3/utils/json/option"
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+)
+
+type MessageInput struct {
+	*tview.InputField
+}
+
+func newMessageInput() *MessageInput {
+	mi := &MessageInput{
+		InputField: tview.NewInputField(),
+	}
+
+	mi.SetInputCapture(mi.onInputCapture)
+	mi.SetBackgroundColor(tcell.GetColor(config.Theme.BackgroundColor))
+	mi.SetFieldBackgroundColor(tcell.GetColor(config.Theme.BackgroundColor))
+
+	mi.SetTitleColor(tcell.GetColor(config.Theme.TitleColor))
+	mi.SetTitleAlign(tview.AlignLeft)
+
+	p := config.Theme.BorderPadding
+	mi.SetBorder(config.Theme.Border)
+	mi.SetBorderColor(tcell.GetColor(config.Theme.BorderColor))
+	mi.SetBorderPadding(p[0], p[1], p[2], p[3])
+
+	return mi
+}
+
+func (mi *MessageInput) reset() {
+	mi.SetTitle("")
+	mi.SetText("")
+}
+
+func (mi *MessageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
+	switch event.Name() {
+	case config.Keys.MessageInput.Send:
+		mi.sendAction()
+		return nil
+	case config.Keys.MessageInput.Paste:
+		mi.pasteAction()
+		return nil
+	case config.Keys.MessageInput.LaunchEditor:
+		messageInput.launchEditorAction()
+		return nil
+	case config.Keys.Cancel:
+		mi.reset()
+		return nil
+	}
+
+	return event
+}
+
+func (mi *MessageInput) sendAction() {
+	if guildsTree.selectedChannel == nil {
+		return
+	}
+
+	text := strings.TrimSpace(mi.GetText())
+	if text == "" {
+		return
+	}
+
+	var err error
+	if messagesText.selectedMessage != -1 {
+		ms, err := discordState.Cabinet.Messages(guildsTree.selectedChannel.ID)
+		if err != nil {
+			log.Println(err)
+			return
+		}
+
+		data := api.SendMessageData{
+			Content:         text,
+			Reference:       &discord.MessageReference{MessageID: ms[messagesText.selectedMessage].ID},
+			AllowedMentions: &api.AllowedMentions{RepliedUser: option.False},
+		}
+
+		if strings.HasPrefix(mi.GetTitle(), "[@]") {
+			data.AllowedMentions.RepliedUser = option.True
+		}
+
+		go discordState.SendMessageComplex(guildsTree.selectedChannel.ID, data)
+	} else {
+		go discordState.SendMessage(guildsTree.selectedChannel.ID, text)
+	}
+
+	if err != nil {
+		log.Println(err)
+		return
+	}
+
+	messagesText.selectedMessage = -1
+	messagesText.Highlight()
+	mi.reset()
+}
+
+func (mi *MessageInput) pasteAction() {
+	text, err := clipboard.ReadAll()
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	// Append the text to the message input.
+	mi.SetText(mi.GetText() + text)
+}
+
+func (mi *MessageInput) launchEditorAction() {
+	e := config.Editor
+	if e == "default" {
+		e = os.Getenv("EDITOR")
+	}
+
+	cmd := exec.Command(e)
+	var b strings.Builder
+	cmd.Stdout = &b
+
+	app.Suspend(func() {
+		err := cmd.Run()
+		if err != nil {
+			log.Println(err)
+			return
+		}
+	})
+
+	mi.SetText(b.String())
+}

+ 289 - 0
messages_text.go

@@ -0,0 +1,289 @@
+package main
+
+import (
+	"bytes"
+	"log"
+	"time"
+
+	"github.com/atotto/clipboard"
+	"github.com/ayn2op/discordo/discordmd"
+	"github.com/diamondburned/arikawa/v3/discord"
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+)
+
+const replyIndicator = '╭'
+
+type MessagesText struct {
+	*tview.TextView
+
+	selectedMessage int
+	buf             bytes.Buffer
+}
+
+func newMessagesText() *MessagesText {
+	mt := &MessagesText{
+		TextView: tview.NewTextView(),
+
+		selectedMessage: -1,
+	}
+
+	mt.SetDynamicColors(true)
+	mt.SetRegions(true)
+	mt.SetWordWrap(true)
+	mt.SetInputCapture(mt.onInputCapture)
+	mt.ScrollToEnd()
+	mt.SetChangedFunc(func() {
+		app.Draw()
+	})
+
+	mt.SetBackgroundColor(tcell.GetColor(config.Theme.BackgroundColor))
+
+	mt.SetTitle("Messages")
+	mt.SetTitleColor(tcell.GetColor(config.Theme.TitleColor))
+	mt.SetTitleAlign(tview.AlignLeft)
+
+	p := config.Theme.BorderPadding
+	mt.SetBorder(config.Theme.Border)
+	mt.SetBorderColor(tcell.GetColor(config.Theme.BorderColor))
+	mt.SetBorderPadding(p[0], p[1], p[2], p[3])
+
+	return mt
+}
+
+func (mt *MessagesText) reset() {
+	messagesText.selectedMessage = -1
+
+	mt.SetTitle("")
+	mt.Clear()
+	mt.Highlight()
+}
+
+func (mt *MessagesText) createMessage(m *discord.Message) error {
+	switch m.Type {
+	case discord.DefaultMessage, discord.InlinedReplyMessage:
+		// Region tags are square brackets that contain a region ID in double quotes
+		// https://pkg.go.dev/github.com/rivo/tview#hdr-Regions_and_Highlights
+		mt.buf.WriteString(`["`)
+		mt.buf.WriteString(m.ID.String())
+		mt.buf.WriteString(`"]`)
+
+		if m.ReferencedMessage != nil {
+			mt.buf.WriteString("[::d] ")
+			mt.buf.WriteRune(replyIndicator)
+			mt.buf.WriteByte(' ')
+
+			mt.buf.WriteByte('[')
+			mt.buf.WriteString(config.Theme.MessagesText.AuthorColor)
+			mt.buf.WriteByte(']')
+			mt.buf.WriteString(m.ReferencedMessage.Author.Username)
+			mt.buf.WriteString("[-] ")
+
+			mt.buf.WriteString(discordmd.Parse(tview.Escape(m.ReferencedMessage.Content)))
+			mt.buf.WriteString("[::-]\n")
+		}
+
+		mt.createHeader(m)
+		mt.createBody(m)
+		mt.createFooter(m)
+
+		// Tags with no region ID ([""]) don't start new regions. They can therefore be used to mark the end of a region.
+		mt.buf.WriteString(`[""]`)
+		mt.buf.WriteByte('\n')
+	}
+
+	_, err := mt.buf.WriteTo(mt)
+	return err
+}
+
+func (mt *MessagesText) createHeader(m *discord.Message) {
+	mt.buf.WriteByte('[')
+	mt.buf.WriteString(config.Theme.MessagesText.AuthorColor)
+	mt.buf.WriteByte(']')
+	mt.buf.WriteString(m.Author.Username)
+	mt.buf.WriteString("[-] ")
+
+	if config.Timestamps {
+		mt.buf.WriteString("[::d]")
+		mt.buf.WriteString(m.Timestamp.Format(time.Kitchen))
+		mt.buf.WriteString("[::-] ")
+	}
+}
+
+func (mt *MessagesText) createBody(m *discord.Message) {
+	mt.buf.WriteString(discordmd.Parse(tview.Escape(m.Content)))
+}
+
+func (mt *MessagesText) createFooter(m *discord.Message) {
+	for _, a := range m.Attachments {
+		mt.buf.WriteByte('\n')
+
+		mt.buf.WriteByte('[')
+		mt.buf.WriteString(a.Filename)
+		mt.buf.WriteString("]: ")
+		mt.buf.WriteString(a.URL)
+	}
+}
+
+func (mt *MessagesText) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
+	switch event.Name() {
+	case config.Keys.MessagesText.CopyContent:
+		mt.copyContentAction()
+		return nil
+	case config.Keys.MessagesText.Reply:
+		mt.replyAction(false)
+		return nil
+	case config.Keys.MessagesText.ReplyMention:
+		mt.replyAction(true)
+		return nil
+	case config.Keys.MessagesText.SelectPrevious:
+		mt.selectPreviousAction()
+		return nil
+	case config.Keys.MessagesText.SelectNext:
+		mt.selectNextAction()
+		return nil
+	case config.Keys.MessagesText.SelectFirst:
+		mt.selectFirstAction()
+		return nil
+	case config.Keys.MessagesText.SelectLast:
+		mt.selectLastAction()
+		return nil
+	case config.Keys.MessagesText.SelectReply:
+		mt.selectReplyAction()
+		return nil
+	case config.Keys.Cancel:
+		guildsTree.selectedChannel = nil
+
+		messagesText.reset()
+		messageInput.reset()
+		return nil
+	}
+
+	return event
+}
+
+func (mt *MessagesText) replyAction(mention bool) {
+	if mt.selectedMessage == -1 {
+		return
+	}
+
+	var title string
+	if mention {
+		title += "[@] Replying to "
+	} else {
+		title += "Replying to "
+	}
+
+	ms, err := discordState.Cabinet.Messages(guildsTree.selectedChannel.ID)
+	if err != nil {
+		log.Println(err)
+		return
+	}
+
+	title += ms[mt.selectedMessage].Author.Tag()
+	messageInput.SetTitle(title)
+
+	app.SetFocus(messageInput)
+}
+
+func (mt *MessagesText) selectPreviousAction() {
+	ms, err := discordState.Cabinet.Messages(guildsTree.selectedChannel.ID)
+	if err != nil {
+		log.Println(err)
+		return
+	}
+
+	// If no message is currently selected, select the latest message.
+	if len(mt.GetHighlights()) == 0 {
+		mt.selectedMessage = 0
+	} else {
+		if mt.selectedMessage < len(ms)-1 {
+			mt.selectedMessage++
+		}
+	}
+
+	mt.Highlight(ms[mt.selectedMessage].ID.String())
+	mt.ScrollToHighlight()
+}
+
+func (mt *MessagesText) selectNextAction() {
+	ms, err := discordState.Cabinet.Messages(guildsTree.selectedChannel.ID)
+	if err != nil {
+		log.Println(err)
+		return
+	}
+
+	// If no message is currently selected, select the latest message.
+	if len(mt.GetHighlights()) == 0 {
+		mt.selectedMessage = 0
+	} else {
+		if mt.selectedMessage > 0 {
+			mt.selectedMessage--
+		}
+	}
+
+	mt.Highlight(ms[mt.selectedMessage].ID.String())
+	mt.ScrollToHighlight()
+}
+
+func (mt *MessagesText) selectFirstAction() {
+	ms, err := discordState.Cabinet.Messages(guildsTree.selectedChannel.ID)
+	if err != nil {
+		log.Println(err)
+		return
+	}
+
+	mt.selectedMessage = len(ms) - 1
+	mt.Highlight(ms[mt.selectedMessage].ID.String())
+	mt.ScrollToHighlight()
+}
+
+func (mt *MessagesText) selectLastAction() {
+	ms, err := discordState.Cabinet.Messages(guildsTree.selectedChannel.ID)
+	if err != nil {
+		log.Println(err)
+		return
+	}
+
+	mt.selectedMessage = 0
+	mt.Highlight(ms[mt.selectedMessage].ID.String())
+	mt.ScrollToHighlight()
+}
+
+func (mt *MessagesText) selectReplyAction() {
+	if mt.selectedMessage == -1 {
+		return
+	}
+
+	ms, err := discordState.Cabinet.Messages(guildsTree.selectedChannel.ID)
+	if err != nil {
+		log.Println(err)
+		return
+	}
+
+	ref := ms[mt.selectedMessage].ReferencedMessage
+	if ref != nil {
+		for i, m := range ms {
+			if ref.ID == m.ID {
+				mt.selectedMessage = i
+			}
+		}
+
+		mt.Highlight(ms[mt.selectedMessage].ID.String())
+		mt.ScrollToHighlight()
+	}
+}
+
+func (mt *MessagesText) copyContentAction() {
+	ms, err := discordState.Cabinet.Messages(guildsTree.selectedChannel.ID)
+	if err != nil {
+		log.Println(err)
+		return
+	}
+
+	err = clipboard.WriteAll(ms[mt.selectedMessage].Content)
+	if err != nil {
+		log.Println(err)
+		return
+	}
+}

+ 76 - 0
state.go

@@ -0,0 +1,76 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"runtime"
+
+	"github.com/diamondburned/arikawa/v3/api"
+	"github.com/diamondburned/arikawa/v3/gateway"
+	"github.com/diamondburned/arikawa/v3/state"
+	"github.com/rivo/tview"
+)
+
+func init() {
+	api.UserAgent = fmt.Sprintf("%s/%s %s/%s", name, "0.1", "arikawa", "v3")
+	gateway.DefaultIdentity = gateway.IdentifyProperties{
+		OS:      runtime.GOOS,
+		Browser: name,
+		Device:  "",
+	}
+}
+
+type State struct {
+	*state.State
+}
+
+func newState(token string) *State {
+	s := &State{
+		State: state.New(token),
+	}
+
+	s.AddHandler(s.onReady)
+	s.AddHandler(s.onMessageCreate)
+
+	return s
+}
+
+func (s *State) onReady(r *gateway.ReadyEvent) {
+	dmNode := tview.NewTreeNode("Direct Messages")
+	guildsTree.root.AddChild(dmNode)
+
+	for _, gf := range r.UserSettings.GuildFolders {
+		/// If the ID of the guild folder is zero, the guild folder only contains single guild.
+		if gf.ID == 0 {
+			err := guildsTree.createGuildNodeFromID(guildsTree.root, gf.GuildIDs[0])
+			if err != nil {
+				log.Println(err)
+				continue
+			}
+		} else {
+			gfNode := tview.NewTreeNode("Folder")
+			guildsTree.root.AddChild(gfNode)
+
+			for _, gid := range gf.GuildIDs {
+				err := guildsTree.createGuildNodeFromID(gfNode, gid)
+				if err != nil {
+					log.Println(err)
+					continue
+				}
+			}
+		}
+	}
+
+	guildsTree.SetCurrentNode(guildsTree.root)
+	app.SetFocus(guildsTree)
+}
+
+func (s *State) onMessageCreate(m *gateway.MessageCreateEvent) {
+	if guildsTree.selectedChannel != nil && guildsTree.selectedChannel.ID == m.ChannelID {
+		err := messagesText.createMessage(&m.Message)
+		if err != nil {
+			log.Println(err)
+			return
+		}
+	}
+}

+ 0 - 233
ui/actions_list.go

@@ -1,233 +0,0 @@
-package ui
-
-import (
-	"io"
-	"net/http"
-	"os"
-	"path/filepath"
-	"regexp"
-
-	"github.com/atotto/clipboard"
-	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/rivo/tview"
-	"github.com/skratchdot/open-golang/open"
-)
-
-var linkRegex = regexp.MustCompile("https?://.+")
-
-type ActionsList struct {
-	*tview.List
-	app     *Application
-	message *discord.Message
-}
-
-func newActionsList(app *Application, m *discord.Message) *ActionsList {
-	v := &ActionsList{
-		List:    tview.NewList(),
-		app:     app,
-		message: m,
-	}
-
-	v.ShowSecondaryText(false)
-	v.SetDoneFunc(func() {
-		app.SetRoot(app.view, true)
-		app.SetFocus(app.view.MessagesText)
-	})
-
-	isDM := channelIsInDMCategory(app.view.ChannelsTree.selected)
-
-	// If the client user has the `SEND_MESSAGES` permission, add "Reply" and "Mention Reply" actions.
-	if isDM || !isDM && hasPermission(app.state, app.view.ChannelsTree.selected.ID, discord.PermissionSendMessages) {
-		v.AddItem("Reply", "", 'r', v.replyAction)
-		v.AddItem("Mention Reply", "", 'R', v.mentionReplyAction)
-	}
-
-	// If the referenced message exists, add a new action to select the reply.
-	if m.ReferencedMessage != nil {
-		v.AddItem("Select Reply", "", 'm', v.selectReplyAction)
-	}
-
-	// If the content of the message contains link(s), add the appropriate actions.
-	links := linkRegex.FindAllString(m.Content, -1)
-	if len(links) != 0 {
-		v.AddItem("Open Link", "", 'l', func() {
-			for _, l := range links {
-				go open.Run(l)
-			}
-
-			app.SetRoot(app.view, true)
-			app.SetFocus(app.view.MessagesText)
-		})
-	}
-
-	// If the message contains attachments, add the appropriate actions to the actions view.
-	if len(m.Attachments) != 0 {
-		v.AddItem("Open Attachment", "", 'o', v.openAttachmentAction)
-		v.AddItem("Download Attachment", "", 'd', v.downloadAttachmentAction)
-	}
-
-	me, _ := app.state.MeStore.Me()
-
-	// If the client user has the `MANAGE_MESSAGES` permission, add a new action to delete the message.
-	if (isDM && m.Author.ID == me.ID) || (!isDM && hasPermission(app.state, app.view.ChannelsTree.selected.ID, discord.PermissionManageMessages)) {
-		v.AddItem("Delete", "", 'd', v.deleteAction)
-	}
-
-	v.AddItem("Copy Content", "", 'c', v.copyContentAction)
-	v.AddItem("Copy ID", "", 'i', v.copyIDAction)
-	v.AddItem("Copy Link", "", 'k', v.copyLinkAction)
-
-	v.SetTitle("Press the Escape key to close")
-	v.SetTitleAlign(tview.AlignLeft)
-	v.SetBorder(true)
-	v.SetBorderPadding(0, 0, 1, 1)
-
-	return v
-}
-
-func (al *ActionsList) replyAction() {
-	al.app.view.MessageInput.SetTitle("Replying to " + al.message.Author.Tag())
-
-	al.app.SetRoot(al.app.view, true)
-	al.app.SetFocus(al.app.view.MessageInput)
-}
-
-func (al *ActionsList) mentionReplyAction() {
-	al.app.view.MessageInput.SetTitle("[@] Replying to " + al.message.Author.Tag())
-
-	al.app.SetRoot(al.app.view, true)
-	al.app.SetFocus(al.app.view.MessageInput)
-}
-
-func (al *ActionsList) selectReplyAction() {
-	ms, err := al.app.state.Cabinet.Messages(al.message.ChannelID)
-	if err != nil {
-		return
-	}
-
-	al.app.view.MessagesText.selected, _ = findMessageByID(ms, al.message.ReferencedMessage.ID)
-	al.app.view.MessagesText.
-		Highlight(al.message.ReferencedMessage.ID.String()).
-		ScrollToHighlight()
-
-	al.app.SetRoot(al.app.view, true)
-	al.app.SetFocus(al.app.view.MessagesText)
-}
-
-func (al *ActionsList) openAttachmentAction() {
-	for _, a := range al.message.Attachments {
-		cacheDirPath, _ := os.UserCacheDir()
-		f, err := os.Create(filepath.Join(cacheDirPath, a.Filename))
-		if err != nil {
-			return
-		}
-		defer f.Close()
-
-		resp, err := http.Get(a.URL)
-		if err != nil {
-			return
-		}
-
-		d, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return
-		}
-
-		f.Write(d)
-		go open.Run(f.Name())
-	}
-
-	al.app.SetRoot(al.app.view, true)
-	al.app.SetFocus(al.app.view.MessagesText)
-}
-
-func (al *ActionsList) downloadAttachmentAction() {
-	for _, a := range al.message.Attachments {
-		path, err := os.UserHomeDir()
-		if err != nil {
-			path = os.TempDir()
-		}
-
-		path = filepath.Join(path, "Downloads", a.Filename)
-		f, err := os.Create(path)
-		if err != nil {
-			return
-		}
-		defer f.Close()
-
-		resp, err := http.Get(a.URL)
-		if err != nil {
-			return
-		}
-
-		d, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return
-		}
-
-		f.Write(d)
-	}
-
-	al.app.SetRoot(al.app.view, true)
-	al.app.SetFocus(al.app.view.MessagesText)
-}
-
-func (al *ActionsList) deleteAction() {
-	al.app.view.MessagesText.Clear()
-
-	err := al.app.state.MessageRemove(al.message.ChannelID, al.message.ID)
-	if err != nil {
-		return
-	}
-
-	err = al.app.state.DeleteMessage(al.message.ChannelID, al.message.ID, "Unknown")
-	if err != nil {
-		return
-	}
-
-	// The returned slice will be sorted from latest to oldest.
-	ms, err := al.app.state.Cabinet.Messages(al.message.ChannelID)
-	if err != nil {
-		return
-	}
-
-	for i := len(ms) - 1; i >= 0; i-- {
-		_, err = al.app.view.MessagesText.Write(buildMessage(al.app, ms[i]))
-		if err != nil {
-			return
-		}
-	}
-
-	al.app.SetRoot(al.app.view, true)
-	al.app.SetFocus(al.app.view.MessagesText)
-}
-
-func (al *ActionsList) copyContentAction() {
-	err := clipboard.WriteAll(al.message.Content)
-	if err != nil {
-		return
-	}
-
-	al.app.SetRoot(al.app.view, true)
-	al.app.SetFocus(al.app.view.MessagesText)
-}
-
-func (al *ActionsList) copyIDAction() {
-	err := clipboard.WriteAll(al.message.ID.String())
-	if err != nil {
-		return
-	}
-
-	al.app.SetRoot(al.app.view, true)
-	al.app.SetFocus(al.app.view.MessagesText)
-}
-
-func (al *ActionsList) copyLinkAction() {
-	err := clipboard.WriteAll(al.message.URL())
-	if err != nil {
-		return
-	}
-
-	al.app.SetRoot(al.app.view, true)
-	al.app.SetFocus(al.app.view.MessagesText)
-}

+ 0 - 203
ui/application.go

@@ -1,203 +0,0 @@
-package ui
-
-import (
-	"context"
-	"fmt"
-	"log"
-	"runtime"
-	"strings"
-
-	"github.com/ayn2op/discordo/config"
-	"github.com/diamondburned/arikawa/v3/api"
-	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/diamondburned/arikawa/v3/gateway"
-	"github.com/diamondburned/arikawa/v3/state"
-	"github.com/gdamore/tcell/v2"
-	"github.com/rivo/tview"
-)
-
-func init() {
-	tview.Borders.TopLeftFocus = tview.Borders.TopLeft
-	tview.Borders.TopRightFocus = tview.Borders.TopRight
-	tview.Borders.BottomLeftFocus = tview.Borders.BottomLeft
-	tview.Borders.BottomRightFocus = tview.Borders.BottomRight
-	tview.Borders.HorizontalFocus = tview.Borders.Horizontal
-	tview.Borders.VerticalFocus = tview.Borders.Vertical
-	tview.Borders.TopLeft = 0
-	tview.Borders.TopRight = 0
-	tview.Borders.BottomLeft = 0
-	tview.Borders.BottomRight = 0
-	tview.Borders.Horizontal = 0
-	tview.Borders.Vertical = 0
-
-	api.UserAgent = fmt.Sprintf("%s/%s %s/%s", config.Name, "0.1", "arikawa", "v3")
-	gateway.DefaultIdentity = gateway.IdentifyProperties{
-		OS:      runtime.GOOS,
-		Browser: config.Name,
-		Device:  "",
-	}
-}
-
-// Application is responsible for initialization and management of the application, widgets, configuration, and state.
-type Application struct {
-	*tview.Application
-
-	view   *View
-	config *config.Config
-	state  *state.State
-}
-
-func NewApplication(cfg *config.Config) *Application {
-	app := &Application{
-		Application: tview.NewApplication(),
-		config:      cfg,
-	}
-
-	app.EnableMouse(app.config.Mouse)
-	app.SetBeforeDrawFunc(app.onBeforeDraw)
-
-	// The styles must be assigned before initializing a new view.
-	tview.Styles.PrimitiveBackgroundColor = tcell.GetColor(cfg.Theme.Background)
-	tview.Styles.BorderColor = tcell.GetColor(cfg.Theme.Border)
-	tview.Styles.TitleColor = tcell.GetColor(cfg.Theme.Title)
-
-	app.view = newView(app)
-
-	return app
-}
-
-func (app *Application) Run(token string) {
-	if token != "" {
-		if err := app.Connect(token); err != nil {
-			log.Fatal(err)
-		}
-
-		app.SetRoot(app.view, true)
-		app.SetFocus(app.view.GuildsTree)
-	} else {
-		loginView := newLoginForm(app)
-		app.SetRoot(loginView, true)
-	}
-
-	if err := app.Application.Run(); err != nil {
-		log.Fatal(err)
-	}
-}
-
-func (app *Application) Connect(token string) error {
-	app.state = state.New(token)
-	app.state.AddHandler(app.onReady)
-	app.state.AddHandler(app.onGuildCreate)
-	app.state.AddHandler(app.onGuildDelete)
-	app.state.AddHandler(app.onMessageCreate)
-
-	return app.state.Open(context.Background())
-}
-
-func (app *Application) onBeforeDraw(screen tcell.Screen) bool {
-	if app.config.Theme.Background == "default" {
-		screen.Clear()
-	}
-
-	return false
-}
-
-func (c *Application) onReady(r *gateway.ReadyEvent) {
-	root := c.view.GuildsTree.GetRoot()
-	for _, gf := range r.UserSettings.GuildFolders {
-		if gf.ID == 0 {
-			for _, gID := range gf.GuildIDs {
-				g, err := c.state.Cabinet.Guild(gID)
-				if err != nil {
-					log.Println(err)
-					continue
-				}
-
-				guildNode := tview.NewTreeNode(g.Name)
-				guildNode.SetReference(g.ID)
-				root.AddChild(guildNode)
-			}
-		} else {
-			var b strings.Builder
-
-			if gf.Color != discord.NullColor {
-				b.WriteByte('[')
-				b.WriteString(gf.Color.String())
-				b.WriteByte(']')
-			} else {
-				b.WriteString("[#ED4245]")
-			}
-
-			if gf.Name != "" {
-				b.WriteString(gf.Name)
-			} else {
-				b.WriteString("Folder")
-			}
-
-			b.WriteString("[-]")
-
-			folderNode := tview.NewTreeNode(b.String())
-			root.AddChild(folderNode)
-
-			for _, gID := range gf.GuildIDs {
-				g, err := c.state.Cabinet.Guild(gID)
-				if err != nil {
-					log.Println(err)
-					continue
-				}
-
-				guildNode := tview.NewTreeNode(g.Name)
-				guildNode.SetReference(g.ID)
-				folderNode.AddChild(guildNode)
-			}
-		}
-
-	}
-
-	c.view.GuildsTree.SetCurrentNode(root)
-	c.SetFocus(c.view.GuildsTree)
-}
-
-func (c *Application) onGuildCreate(g *gateway.GuildCreateEvent) {
-	guildNode := tview.NewTreeNode(g.Name)
-	guildNode.SetReference(g.ID)
-
-	rootNode := c.view.GuildsTree.GetRoot()
-	rootNode.AddChild(guildNode)
-
-	c.view.GuildsTree.SetCurrentNode(rootNode)
-	c.SetFocus(c.view.GuildsTree)
-	c.Draw()
-}
-
-func (c *Application) onGuildDelete(g *gateway.GuildDeleteEvent) {
-	rootNode := c.view.GuildsTree.GetRoot()
-	var parentNode *tview.TreeNode
-	rootNode.Walk(func(node, _ *tview.TreeNode) bool {
-		if node.GetReference() == g.ID {
-			parentNode = node
-			return false
-		}
-
-		return true
-	})
-
-	if parentNode != nil {
-		rootNode.RemoveChild(parentNode)
-	}
-
-	c.Draw()
-}
-
-func (c *Application) onMessageCreate(m *gateway.MessageCreateEvent) {
-	if c.view.ChannelsTree.selected != nil && m.ChannelID == c.view.ChannelsTree.selected.ID {
-		_, err := c.view.MessagesText.Write(buildMessage(c, m.Message))
-		if err != nil {
-			return
-		}
-
-		if len(c.view.MessagesText.GetHighlights()) == 0 {
-			c.view.MessagesText.ScrollToEnd()
-		}
-	}
-}

+ 0 - 231
ui/builder.go

@@ -1,231 +0,0 @@
-package ui
-
-import (
-	"fmt"
-	"strings"
-	"time"
-
-	"github.com/ayn2op/discordo/discordmd"
-	"github.com/diamondburned/arikawa/v3/discord"
-)
-
-func buildMessage(c *Application, m discord.Message) []byte {
-	var b strings.Builder
-
-	switch m.Type {
-	case discord.DefaultMessage, discord.InlinedReplyMessage:
-		// Define a new region and assign message ID as the region ID.
-		// Learn more:
-		// https://pkg.go.dev/github.com/rivo/tview#hdr-Regions_and_Highlights
-		b.WriteString("[\"")
-		b.WriteString(m.ID.String())
-		b.WriteString("\"]")
-		// Build the message associated with crosspost, channel follow add, pin, or a reply.
-		buildReferencedMessage(&b, m.ReferencedMessage, c.state.Ready().User.ID)
-
-		if c.config.Timestamps {
-			loc, err := time.LoadLocation(c.config.Timezone)
-			if err != nil {
-				return nil
-			}
-
-			b.WriteString("[::d]")
-			b.WriteString(m.Timestamp.Time().In(loc).Format(c.config.TimeFormat))
-			b.WriteString("[::-]")
-			b.WriteByte(' ')
-		}
-
-		// Build the author of this message.
-		buildAuthor(&b, m.Author, c.state.Ready().User.ID)
-
-		// Build the contents of the message.
-		buildContent(&b, m, c.state.Ready().User.ID)
-
-		if m.EditedTimestamp.IsValid() {
-			b.WriteString(" [::d](edited)[::-]")
-		}
-
-		// Build the embeds associated with the message.
-		buildEmbeds(&b, m.Embeds)
-
-		// Build the message attachments (attached files to the message).
-		buildAttachments(&b, m.Attachments)
-
-		// Tags with no region ID ([""]) do not start new regions. They can
-		// therefore be used to mark the end of a region.
-		b.WriteString("[\"\"]")
-
-		b.WriteByte('\n')
-	case discord.GuildMemberJoinMessage:
-		b.WriteString("[#5865F2]")
-		b.WriteString(m.Author.Username)
-		b.WriteString("[-] joined the server.")
-
-		b.WriteByte('\n')
-	case discord.CallMessage:
-		b.WriteString("[#5865F2]")
-		b.WriteString(m.Author.Username)
-		b.WriteString("[-] started a call.")
-
-		b.WriteByte('\n')
-	case discord.ChannelPinnedMessage:
-		b.WriteString("[#5865F2]")
-		b.WriteString(m.Author.Username)
-		b.WriteString("[-] pinned a message.")
-
-		b.WriteByte('\n')
-	}
-
-	if str := b.String(); str != "" {
-		b := make([]byte, len(str)+1)
-		copy(b, str)
-
-		return b
-	}
-
-	return nil
-}
-
-func buildReferencedMessage(b *strings.Builder, rm *discord.Message, clientID discord.UserID) {
-	if rm != nil {
-		b.WriteString(" ╭ ")
-		b.WriteString("[::d]")
-		buildAuthor(b, rm.Author, clientID)
-
-		if rm.Content != "" {
-			rm.Content = buildMentions(rm.Content, rm.Mentions, clientID)
-			b.WriteString(discordmd.Parse(rm.Content))
-		}
-
-		b.WriteString("[::-]")
-		b.WriteByte('\n')
-	}
-}
-
-func buildContent(b *strings.Builder, m discord.Message, clientID discord.UserID) {
-	if m.Content != "" {
-		m.Content = buildMentions(m.Content, m.Mentions, clientID)
-		b.WriteString(discordmd.Parse(m.Content))
-	}
-}
-
-func buildEmbeds(b *strings.Builder, es []discord.Embed) {
-	for _, e := range es {
-		if e.Type != discord.NormalEmbed {
-			continue
-		}
-
-		var (
-			embedBuilder strings.Builder
-			hasHeading   bool
-		)
-		prefix := fmt.Sprintf("[#%06X]▐[-] ", e.Color)
-
-		b.WriteByte('\n')
-		embedBuilder.WriteString(prefix)
-
-		if e.Author != nil {
-			hasHeading = true
-			embedBuilder.WriteString("[::u]")
-			embedBuilder.WriteString(e.Author.Name)
-			embedBuilder.WriteString("[::-]")
-		}
-
-		if e.Title != "" {
-			if hasHeading {
-				embedBuilder.WriteByte('\n')
-				embedBuilder.WriteByte('\n')
-			}
-
-			embedBuilder.WriteString("[::b]")
-			embedBuilder.WriteString(e.Title)
-			embedBuilder.WriteString("[::-]")
-		}
-
-		if e.Description != "" {
-			if hasHeading {
-				embedBuilder.WriteByte('\n')
-				embedBuilder.WriteByte('\n')
-			}
-
-			embedBuilder.WriteString(discordmd.Parse(e.Description))
-		}
-
-		if len(e.Fields) != 0 {
-			if hasHeading || e.Description != "" {
-				embedBuilder.WriteByte('\n')
-				embedBuilder.WriteByte('\n')
-			}
-
-			for i, ef := range e.Fields {
-				embedBuilder.WriteString("[::b]")
-				embedBuilder.WriteString(ef.Name)
-				embedBuilder.WriteString("[::-]")
-				embedBuilder.WriteByte('\n')
-				embedBuilder.WriteString(discordmd.Parse(ef.Value))
-
-				if i != len(e.Fields)-1 {
-					embedBuilder.WriteString("\n\n")
-				}
-			}
-		}
-
-		if e.Footer != nil {
-			if hasHeading {
-				embedBuilder.WriteString("\n\n")
-			}
-
-			embedBuilder.WriteString(e.Footer.Text)
-		}
-
-		b.WriteString(strings.ReplaceAll(embedBuilder.String(), "\n", "\n"+prefix))
-	}
-}
-
-func buildAttachments(b *strings.Builder, as []discord.Attachment) {
-	for _, a := range as {
-		b.WriteByte('\n')
-		b.WriteByte('[')
-		b.WriteString(a.Filename)
-		b.WriteString("]: ")
-		b.WriteString(a.URL)
-	}
-}
-
-func buildMentions(content string, mentions []discord.GuildUser, clientID discord.UserID) string {
-	for _, mUser := range mentions {
-		var color string
-		if mUser.ID == clientID {
-			color = "[:#5865F2]"
-		} else {
-			color = "[#EB459E]"
-		}
-
-		content = strings.NewReplacer(
-			// <@USER_ID>
-			"<@"+mUser.ID.String()+">",
-			color+"@"+mUser.Username+"[-:-]",
-			// <@!USER_ID>
-			"<@!"+mUser.ID.String()+">",
-			color+"@"+mUser.Username+"[-:-]",
-		).Replace(content)
-	}
-
-	return content
-}
-
-func buildAuthor(b *strings.Builder, u discord.User, clientID discord.UserID) {
-	if u.ID == clientID {
-		b.WriteString("[#57F287]")
-	} else {
-		b.WriteString("[#ED4245]")
-	}
-
-	b.WriteString(u.Username)
-	b.WriteString("[-] ")
-	// If the message author is a bot account, render the message with bot label
-	// for distinction.
-	if u.Bot {
-		b.WriteString("[#EB459E]BOT[-] ")
-	}
-}

+ 0 - 163
ui/channels_tree.go

@@ -1,163 +0,0 @@
-package ui
-
-import (
-	"log"
-	"sort"
-
-	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/rivo/tview"
-)
-
-type ChannelsTree struct {
-	*tview.TreeView
-
-	app      *Application
-	selected *discord.Channel
-}
-
-func newChannelsTree(app *Application) *ChannelsTree {
-	v := &ChannelsTree{
-		TreeView: tview.NewTreeView(),
-
-		app: app,
-	}
-
-	v.SetRoot(tview.NewTreeNode(""))
-	v.SetTopLevel(1)
-	v.SetSelectedFunc(v.onSelected)
-
-	v.SetTitle("Channels")
-	v.SetTitleAlign(tview.AlignLeft)
-	v.SetBorder(true)
-	v.SetBorderPadding(0, 0, 1, 1)
-
-	return v
-}
-
-func (v *ChannelsTree) onSelected(node *tview.TreeNode) {
-	v.selected = nil
-	v.app.view.MessagesText.selected = -1
-	v.app.view.MessagesText.
-		Highlight().
-		Clear().
-		SetTitle("")
-	v.app.view.MessageInput.SetText("")
-
-	ref := node.GetReference()
-	if ref == nil {
-		log.Println("selected channel reference is nil")
-		return
-	}
-
-	c, err := v.app.state.Cabinet.Channel(ref.(discord.ChannelID))
-	if err != nil {
-		log.Println("selected channel not found")
-		return
-	}
-
-	switch c.Type {
-	// If the channel is a category channel, expand the selected node if it is collapsed, otherwise collapse.
-	case discord.GuildCategory:
-		node.SetExpanded(!node.IsExpanded())
-		return
-
-	default:
-		v.selected = c
-
-		v.app.view.MessagesText.setTitle(c)
-		v.app.SetFocus(v.app.view.MessageInput)
-
-		go v.app.view.MessagesText.loadMessages(c)
-	}
-}
-
-func (v *ChannelsTree) createChannelNode(c discord.Channel) *tview.TreeNode {
-	channelNode := tview.NewTreeNode(channelToString(c))
-	channelNode.SetReference(c.ID)
-
-	return channelNode
-}
-
-func (v *ChannelsTree) createPrivateChannelNodes(root *tview.TreeNode) {
-	cs, err := v.app.state.Cabinet.PrivateChannels()
-	if err != nil {
-		log.Println(err)
-		return
-	}
-
-	sort.Slice(cs, func(i, j int) bool {
-		idUsed := discord.MessageID(cs[i].ID)
-		idUsed2 := discord.MessageID(cs[j].ID)
-		if cs[i].LastMessageID.IsValid() {
-			idUsed = cs[i].LastMessageID
-		}
-		if cs[j].LastMessageID.IsValid() {
-			idUsed2 = cs[j].LastMessageID
-		}
-		return idUsed > idUsed2
-	})
-
-	for _, c := range cs {
-		root.AddChild(v.createChannelNode(c))
-	}
-}
-
-func (v *ChannelsTree) createGuildChannelNodes(root *tview.TreeNode, gID discord.GuildID) {
-	cs, err := v.app.state.Cabinet.Channels(gID)
-	if err != nil {
-		log.Println(err)
-		return
-	}
-
-	sort.Slice(cs, func(i, j int) bool {
-		return cs[i].Position < cs[j].Position
-	})
-
-	v.createOrphanChannelNodes(root, cs)
-	v.createCategoryChannelNodes(root, cs)
-	v.createChildrenChannelNodes(root, cs)
-}
-
-func (v *ChannelsTree) createOrphanChannelNodes(root *tview.TreeNode, cs []discord.Channel) {
-	for _, c := range cs {
-		if (c.Type == discord.GuildText || c.Type == discord.GuildNews) && (!c.ParentID.IsValid()) {
-			root.AddChild(v.createChannelNode(c))
-		}
-	}
-}
-
-func (v *ChannelsTree) createCategoryChannelNodes(root *tview.TreeNode, cs []discord.Channel) {
-CATEGORY:
-	for _, c := range cs {
-		if c.Type == discord.GuildCategory {
-			for _, nestedChannel := range cs {
-				if nestedChannel.ParentID == c.ID {
-					root.AddChild(v.createChannelNode(c))
-					continue CATEGORY
-				}
-			}
-
-			root.AddChild(v.createChannelNode(c))
-		}
-	}
-}
-
-func (v *ChannelsTree) createChildrenChannelNodes(root *tview.TreeNode, cs []discord.Channel) {
-	for _, c := range cs {
-		if (c.Type == discord.GuildText || c.Type == discord.GuildNews) && (c.ParentID.IsValid()) {
-			var parentNode *tview.TreeNode
-			root.Walk(func(node, _ *tview.TreeNode) bool {
-				if node.GetReference() == c.ParentID {
-					parentNode = node
-					return false
-				}
-
-				return true
-			})
-
-			if parentNode != nil {
-				parentNode.AddChild(v.createChannelNode(c))
-			}
-		}
-	}
-}

+ 0 - 63
ui/guilds_tree.go

@@ -1,63 +0,0 @@
-package ui
-
-import (
-	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/rivo/tview"
-)
-
-type GuildsTree struct {
-	*tview.TreeView
-
-	app *Application
-}
-
-func newGuildsTree(app *Application) *GuildsTree {
-	v := &GuildsTree{
-		TreeView: tview.NewTreeView(),
-
-		app: app,
-	}
-
-	root := tview.NewTreeNode("")
-	root.AddChild(tview.NewTreeNode("Direct Messages"))
-
-	v.SetRoot(root)
-	v.SetTopLevel(1)
-	v.SetSelectedFunc(v.onSelected)
-
-	v.SetTitle("Guilds")
-	v.SetTitleAlign(tview.AlignLeft)
-	v.SetBorder(true)
-	v.SetBorderPadding(0, 0, 1, 1)
-
-	return v
-}
-
-func (v *GuildsTree) onSelected(node *tview.TreeNode) {
-	v.app.view.ChannelsTree.selected = nil
-	v.app.view.MessagesText.selected = -1
-	rootNode := v.app.view.ChannelsTree.GetRoot()
-	rootNode.ClearChildren()
-	v.app.view.MessagesText.
-		Highlight().
-		Clear().
-		SetTitle("")
-	v.app.view.MessageInput.SetText("")
-
-	// If the selected node has children (guild folder), expand the selected node if it is collapsed, otherwise collapse.
-	if len(node.GetChildren()) != 0 {
-		node.SetExpanded(!node.IsExpanded())
-		return
-	}
-
-	ref := node.GetReference()
-	// If the reference of the selected node is nil, it must be the direct messages node.
-	if ref == nil {
-		v.app.view.ChannelsTree.createPrivateChannelNodes(rootNode)
-	} else { // Guild
-		v.app.view.ChannelsTree.createGuildChannelNodes(rootNode, ref.(discord.GuildID))
-	}
-
-	v.app.view.ChannelsTree.SetCurrentNode(rootNode)
-	v.app.SetFocus(v.app.view.ChannelsTree)
-}

+ 0 - 75
ui/login_form.go

@@ -1,75 +0,0 @@
-package ui
-
-import (
-	"context"
-	"log"
-
-	"github.com/ayn2op/discordo/config"
-	"github.com/diamondburned/arikawa/v3/api"
-	"github.com/rivo/tview"
-	"github.com/zalando/go-keyring"
-)
-
-type LoginForm struct {
-	*tview.Form
-	app *Application
-}
-
-func newLoginForm(app *Application) *LoginForm {
-	lf := &LoginForm{
-		Form: tview.NewForm(),
-		app:  app,
-	}
-
-	lf.AddInputField("Email", "", 0, nil, nil)
-	lf.AddPasswordField("Password", "", 0, 0, nil)
-	lf.AddPasswordField("Code (optional)", "", 0, 0, nil)
-	lf.AddButton("Login", lf.onLoginButtonSelected)
-
-	lf.SetTitle("Login")
-	lf.SetTitleAlign(tview.AlignLeft)
-	lf.SetBorder(true)
-	lf.SetBorderPadding(1, 1, 1, 1)
-
-	return lf
-}
-
-func (v *LoginForm) onLoginButtonSelected() {
-	email := v.GetFormItem(0).(*tview.InputField).GetText()
-	password := v.GetFormItem(1).(*tview.InputField).GetText()
-	if email == "" || password == "" {
-		return
-	}
-
-	// Make a scratch HTTP client without a token
-	client := api.NewClient("").WithContext(context.Background())
-	// Try to login without TOTP
-	l, err := client.Login(email, password)
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	// If the token is not dispatched in the response and the "mfa" field is set as true, login using MFA instead.
-	if l.Token == "" && l.MFA {
-		code := v.GetFormItem(2).(*tview.InputField).GetText()
-		if code == "" {
-			return
-		}
-
-		// Retry logging in with a 2FA token
-		l, err = client.TOTP(code, l.Ticket)
-		if err != nil {
-			log.Fatal(err)
-		}
-	}
-
-	err = v.app.Connect(l.Token)
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	v.app.SetRoot(v.app.view, true)
-	v.app.SetFocus(v.app.view.GuildsTree)
-
-	go keyring.Set(config.Name, "token", l.Token)
-}

+ 0 - 160
ui/message_input.go

@@ -1,160 +0,0 @@
-package ui
-
-import (
-	"io"
-	"log"
-	"os"
-	"os/exec"
-	"strings"
-
-	"github.com/atotto/clipboard"
-	"github.com/diamondburned/arikawa/v3/api"
-	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/diamondburned/arikawa/v3/utils/json/option"
-	"github.com/gdamore/tcell/v2"
-	"github.com/rivo/tview"
-)
-
-type MessageInput struct {
-	*tview.InputField
-
-	app *Application
-}
-
-func newMessageInput(app *Application) *MessageInput {
-	mi := &MessageInput{
-		InputField: tview.NewInputField(),
-
-		app: app,
-	}
-
-	mi.SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
-	mi.SetPlaceholder("Message...")
-	mi.SetPlaceholderStyle(tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor))
-	mi.SetInputCapture(mi.inputCapture)
-
-	mi.SetTitleAlign(tview.AlignLeft)
-	mi.SetBorder(true)
-	mi.SetBorderPadding(0, 0, 1, 1)
-
-	return mi
-}
-
-func (v *MessageInput) inputCapture(event *tcell.EventKey) *tcell.EventKey {
-	switch event.Name() {
-	case v.app.config.Keys.MessageInput.Send:
-		return v.sendMessage()
-	case v.app.config.Keys.MessageInput.LaunchEditor:
-		return v.openExternalEditor()
-	case v.app.config.Keys.MessageInput.Paste:
-		return v.pasteClipboard()
-	case "Esc":
-		v.
-			SetText("").
-			SetTitle("")
-		v.app.view.MessagesText.selected = -1
-		v.app.view.MessagesText.Highlight()
-		return nil
-	}
-
-	return event
-}
-
-func (v *MessageInput) sendMessage() *tcell.EventKey {
-	if v.app.view.ChannelsTree.selected == nil {
-		return nil
-	}
-
-	t := strings.TrimSpace(v.GetText())
-	if t == "" {
-		return nil
-	}
-
-	ms, err := v.app.state.Messages(v.app.view.ChannelsTree.selected.ID, v.app.config.MessagesLimit)
-	if err != nil {
-		log.Println(err)
-		return nil
-	}
-
-	if len(v.app.view.MessagesText.GetHighlights()) != 0 {
-		mID, err := discord.ParseSnowflake(v.app.view.MessagesText.GetHighlights()[0])
-		if err != nil {
-			log.Println(err)
-			return nil
-		}
-
-		_, m := findMessageByID(ms, discord.MessageID(mID))
-		d := api.SendMessageData{
-			Content: t,
-			Reference: &discord.MessageReference{
-				MessageID: m.ID,
-			},
-			AllowedMentions: &api.AllowedMentions{RepliedUser: option.False},
-		}
-
-		// If the title of the input view has "[@]" as a prefix, send the message as a reply and mention the replied user.
-		if strings.HasPrefix(v.GetTitle(), "[@]") {
-			d.AllowedMentions.RepliedUser = option.True
-		}
-
-		go v.app.state.SendMessageComplex(m.ChannelID, d)
-
-		v.app.view.MessagesText.selected = -1
-		v.app.view.MessagesText.Highlight()
-
-		v.SetTitle("")
-	} else {
-		go v.app.state.SendMessage(v.app.view.ChannelsTree.selected.ID, t)
-	}
-
-	v.SetText("")
-	return nil
-}
-
-func (v *MessageInput) pasteClipboard() *tcell.EventKey {
-	text, err := clipboard.ReadAll()
-	if err != nil {
-		log.Println(err)
-		return nil
-	}
-
-	text = v.GetText() + text
-	v.SetText(text)
-	return nil
-}
-
-func (v *MessageInput) openExternalEditor() *tcell.EventKey {
-	e := os.Getenv("EDITOR")
-	if e == "" {
-		log.Println("environment variable EDITOR is empty")
-		return nil
-	}
-
-	f, err := os.CreateTemp(os.TempDir(), "discordo-*.md")
-	if err != nil {
-		log.Println(err)
-		return nil
-	}
-	defer os.Remove(f.Name())
-
-	cmd := exec.Command(e, f.Name())
-	cmd.Stdin = os.Stdin
-	cmd.Stdout = os.Stdout
-
-	v.app.Suspend(func() {
-		err = cmd.Run()
-		if err != nil {
-			log.Println(err)
-			return
-		}
-	})
-
-	b, err := io.ReadAll(f)
-	if err != nil {
-		log.Println(err)
-		return nil
-	}
-
-	v.SetText(string(b))
-	return nil
-}

+ 0 - 182
ui/messages_text.go

@@ -1,182 +0,0 @@
-package ui
-
-import (
-	"log"
-
-	"github.com/ayn2op/discordo/discordmd"
-	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/gdamore/tcell/v2"
-	"github.com/rivo/tview"
-)
-
-type MessagesText struct {
-	*tview.TextView
-
-	// The index of the currently selected message. A negative index indicates that there is no currently selected message.
-	selected int
-	app      *Application
-}
-
-func newMessagesText(app *Application) *MessagesText {
-	mt := &MessagesText{
-		TextView: tview.NewTextView(),
-
-		selected: -1,
-		app:      app,
-	}
-
-	mt.SetDynamicColors(true)
-	mt.SetRegions(true)
-	mt.SetWordWrap(true)
-	mt.SetInputCapture(mt.onInputCapture)
-	mt.SetChangedFunc(func() {
-		mt.app.Draw()
-	})
-
-	mt.SetTitle("Messages")
-	mt.SetTitleAlign(tview.AlignLeft)
-	mt.SetBorder(true)
-	mt.SetBorderPadding(0, 0, 1, 1)
-
-	return mt
-}
-
-func (v *MessagesText) setTitle(c *discord.Channel) {
-	title := channelToString(*c)
-	if c.Topic != "" {
-		title += " - " + discordmd.Parse(c.Topic)
-	}
-
-	v.SetTitle(title)
-}
-
-func (v *MessagesText) loadMessages(c *discord.Channel) {
-	// The returned slice will be sorted from latest to oldest.
-	ms, err := v.app.state.Messages(c.ID, v.app.config.MessagesLimit)
-	if err != nil {
-		log.Println(err)
-		return
-	}
-
-	for i := len(ms) - 1; i >= 0; i-- {
-		_, err = v.app.view.MessagesText.Write(buildMessage(v.app, ms[i]))
-		if err != nil {
-			log.Println(err)
-			continue
-		}
-	}
-
-	v.ScrollToEnd()
-}
-
-func (v *MessagesText) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
-	if v.app.view.ChannelsTree.selected == nil {
-		return nil
-	}
-
-	// Messages should return messages ordered from latest to earliest.
-	ms, err := v.app.state.Cabinet.Messages(v.app.view.ChannelsTree.selected.ID)
-	if err != nil || len(ms) == 0 {
-		return nil
-	}
-
-	switch e.Name() {
-	case v.app.config.Keys.MessagesText.LaunchActions:
-		return v.openActionsView(ms)
-
-	case v.app.config.Keys.MessagesText.SelectPrevious:
-		return v.selectPreviousMessage(ms)
-	case v.app.config.Keys.MessagesText.SelectNext:
-		return v.selectNextMessage(ms)
-	case v.app.config.Keys.MessagesText.SelectFirst:
-		return v.selectFirstMessage(ms)
-	case v.app.config.Keys.MessagesText.SelectLast:
-		return v.selectLastMessage(ms)
-	case "Esc":
-		v.selected = -1
-		v.app.view.ChannelsTree.selected = nil
-
-		v.app.SetFocus(v.app.view)
-		v.
-			Clear().
-			Highlight().
-			SetTitle("")
-		return nil
-	}
-
-	return e
-}
-
-func (v *MessagesText) selectPreviousMessage(ms []discord.Message) *tcell.EventKey {
-	// If there are no highlighted regions, select the latest (last) message.
-	if len(v.GetHighlights()) == 0 {
-		v.selected = 0
-	} else {
-		// If the selected message is the oldest (first) message, select the latest (last) message.
-		if v.selected == len(ms)-1 {
-			v.selected = 0
-		} else {
-			v.selected++
-		}
-	}
-
-	v.Highlight(ms[v.selected].ID.String())
-	v.ScrollToHighlight()
-	return nil
-}
-
-func (v *MessagesText) selectNextMessage(ms []discord.Message) *tcell.EventKey {
-	// If there are no highlighted regions, select the latest (last) message.
-	if len(v.GetHighlights()) == 0 {
-		v.selected = 0
-	} else {
-		// If the selected message is the latest (last) message, select the oldest (first) message.
-		if v.selected == 0 {
-			v.selected = len(ms) - 1
-		} else {
-			v.selected--
-		}
-	}
-
-	v.
-		Highlight(ms[v.selected].ID.String()).
-		ScrollToHighlight()
-	return nil
-}
-
-func (v *MessagesText) selectFirstMessage(ms []discord.Message) *tcell.EventKey {
-	v.selected = len(ms) - 1
-	v.
-		Highlight(ms[v.selected].ID.String()).
-		ScrollToHighlight()
-	return nil
-}
-
-func (v *MessagesText) selectLastMessage(ms []discord.Message) *tcell.EventKey {
-	v.selected = 0
-	v.
-		Highlight(ms[v.selected].ID.String()).
-		ScrollToHighlight()
-	return nil
-}
-
-func (v *MessagesText) openActionsView(ms []discord.Message) *tcell.EventKey {
-	hs := v.GetHighlights()
-	if len(hs) == 0 {
-		return nil
-	}
-
-	mID, err := discord.ParseSnowflake(hs[0])
-	if err != nil {
-		return nil
-	}
-
-	_, m := findMessageByID(ms, discord.MessageID(mID))
-	if m == nil {
-		return nil
-	}
-
-	actionsView := newActionsList(v.app, m)
-	v.app.SetRoot(actionsView, true)
-	return nil
-}

+ 0 - 58
ui/util.go

@@ -1,58 +0,0 @@
-package ui
-
-import (
-	"strings"
-
-	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/diamondburned/arikawa/v3/state"
-)
-
-func channelToString(c discord.Channel) string {
-	var repr string
-
-	switch c.Type {
-	case discord.GuildText:
-		repr = "#" + c.Name
-	case discord.DirectMessage:
-		rp := c.DMRecipients[0]
-		repr = rp.Username + "#" + rp.Discriminator
-	case discord.GroupDM:
-		repr = c.Name
-		// if the name wasn't loaded, use it as a backup
-		if repr == "" {
-			rps := make([]string, len(c.DMRecipients))
-			for i, r := range c.DMRecipients {
-				rps[i] = r.Username + "#" + r.Discriminator
-			}
-
-			repr = strings.Join(rps, ", ")
-		}
-	default:
-		repr = c.Name
-	}
-
-	return repr
-}
-
-func findMessageByID(ms []discord.Message, mID discord.MessageID) (int, *discord.Message) {
-	for i, m := range ms {
-		if m.ID == mID {
-			return i, &m
-		}
-	}
-
-	return -1, nil
-}
-
-func channelIsInDMCategory(c *discord.Channel) bool {
-	return c.Type == discord.DirectMessage || c.Type == discord.GroupDM
-}
-
-func hasPermission(s *state.State, cID discord.ChannelID, p discord.Permissions) bool {
-	perm, err := s.Permissions(cID, s.Ready().User.ID)
-	if err != nil {
-		return false
-	}
-
-	return perm&p == p
-}

+ 0 - 42
ui/view.go

@@ -1,42 +0,0 @@
-package ui
-
-import (
-	"github.com/rivo/tview"
-)
-
-type View struct {
-	*tview.Flex
-
-	GuildsTree   *GuildsTree
-	ChannelsTree *ChannelsTree
-	MessagesText *MessagesText
-	MessageInput *MessageInput
-
-	app *Application
-}
-
-func newView(app *Application) *View {
-	v := &View{
-		Flex:         tview.NewFlex(),
-		GuildsTree:   newGuildsTree(app),
-		ChannelsTree: newChannelsTree(app),
-		MessagesText: newMessagesText(app),
-		MessageInput: newMessageInput(app),
-
-		app: app,
-	}
-
-	left := tview.NewFlex().
-		SetDirection(tview.FlexRow).
-		AddItem(v.GuildsTree, 10, 1, false).
-		AddItem(v.ChannelsTree, 0, 1, false)
-	right := tview.NewFlex().
-		SetDirection(tview.FlexRow).
-		AddItem(v.MessagesText, 0, 1, false).
-		AddItem(v.MessageInput, 3, 1, false)
-
-	v.AddItem(left, 0, 1, false)
-	v.AddItem(right, 0, 4, false)
-
-	return v
-}