diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 748a26f..bdc55d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: go-version: '^1.19' - name: Build - run: make + run: go build . - uses: actions/upload-artifact@v3 if: ${{ github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} diff --git a/Makefile b/Makefile deleted file mode 100644 index e57a522..0000000 --- a/Makefile +++ /dev/null @@ -1,16 +0,0 @@ -FLAGS=-ldflags "-s -w" - -.PHONY: all -all: clean fmt build - -.PHONY: clean -clean: - go clean - -.PHONY: fmt -fmt: - gofmt -d -e -s . - -.PHONY: build -build: - go build $(FLAGS) diff --git a/README.md b/README.md index 7596c5d..f8cec5b 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ You can download and install a [prebuilt binary here](https://nightly.link/ayn2o ```bash git clone https://github.com/ayn2op/discordo cd discordo -make build +go build . # optional sudo mv ./discordo /usr/local/bin @@ -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 diff --git a/config.go b/config.go new file mode 100644 index 0000000..57bb153 --- /dev/null +++ b/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 +} diff --git a/config/config.go b/config/config.go deleted file mode 100644 index 3c50cbf..0000000 --- a/config/config.go +++ /dev/null @@ -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) -} diff --git a/discordmd/discordmd.go b/discordmd/discordmd.go index 09b326a..4297ba1 100644 --- a/discordmd/discordmd.go +++ b/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 } diff --git a/discordmd/discordmd_test.go b/discordmd/discordmd_test.go new file mode 100644 index 0000000..cd2eadc --- /dev/null +++ b/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) + } +} diff --git a/go.mod b/go.mod index abaf922..a626f4d 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,11 @@ go 1.19 require ( github.com/atotto/clipboard v0.1.4 - github.com/diamondburned/arikawa/v3 v3.1.0 - github.com/gdamore/tcell/v2 v2.5.3 - 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 ) @@ -23,8 +23,8 @@ require ( github.com/mattn/go-runewidth v0.0.14 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.3 // indirect - 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 ) diff --git a/go.sum b/go.sum index 0957641..dc33baa 100644 --- a/go.sum +++ b/go.sum @@ -2,20 +2,21 @@ github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVK 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/diamondburned/arikawa v1.3.14 h1:9Y1r8nlvWA01hQIYVFGr83JTLd5ULPBzNOKvxbAsH1M= +github.com/diamondburned/arikawa v1.3.14/go.mod h1:nIhVIatzTQhPUa7NB8w4koG1RF9gYbpAr8Fj8sKq660= +github.com/diamondburned/arikawa/v3 v3.2.0 h1:aBUhg94pxblT6ks4EV7qxEk44tnl0ico67ydqjVnv9g= +github.com/diamondburned/arikawa/v3 v3.2.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/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/k= +github.com/gdamore/tcell/v2 v2.5.4/go.mod h1:dZgRy5v4iMobMEcWNYBtREnDZAT9DYmfqIkrgEMxLyw= 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.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= 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= @@ -23,55 +24,64 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm 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/tview v0.0.0-20230104153304-892d1a2eb0da h1:3Mh+tcC2KqetuHpWMurDeF+yOgyt4w4qtLIpwSQ3uqo= +github.com/rivo/tview v0.0.0-20230104153304-892d1a2eb0da/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= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.2 h1:f0xmpYiSrHtSNAVgwip93Cg8tuF45HJM6rHq/A5RI/4= +github.com/zalando/go-keyring v0.2.2/go.mod h1:sI3evg9Wvpw3+n4SqplGSJUMwtDeROfD4nsFz4z9PG0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200423211502-4bdfaf469ed5/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= +golang.org/x/sys v0.4.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/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.4.0 h1:O7UWfv5+A2qiuulQk30kVinPoMtoIPeVaKLEgLpVkvg= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= 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/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 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= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= diff --git a/guilds_tree.go b/guilds_tree.go new file mode 100644 index 0000000..8b926e9 --- /dev/null +++ b/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) + } + } +} diff --git a/login_form.go b/login_form.go new file mode 100644 index 0000000..847dadc --- /dev/null +++ b/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) +} diff --git a/main.go b/main.go index 49cda4c..41f8034 100644 --- a/main.go +++ b/main.go @@ -1,32 +1,46 @@ package main import ( + "context" "flag" "log" "os" "path/filepath" - "github.com/ayn2op/discordo/config" - "github.com/ayn2op/discordo/ui" + "github.com/rivo/tview" "github.com/zalando/go-keyring" ) -var tokenFlag string +var ( + token string + + config *Config + discordState *State + + app = tview.NewApplication() + flex = tview.NewFlex() + + guildsTree *GuildsTree + messagesText *MessagesText + messageInput *MessageInput +) func init() { - flag.StringVar(&tokenFlag, "token", "", "The authentication token.") + flag.StringVar(&token, "token", "", "The authentication token.") - path := config.LogDirPath() - err := os.MkdirAll(path, os.ModePerm) + path, err := os.UserCacheDir() 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, name) + err = os.MkdirAll(path, os.ModePerm) + if err != nil { + log.Fatal(err) + } + + 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 +52,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) + } } diff --git a/message_input.go b/message_input.go new file mode 100644 index 0000000..319d415 --- /dev/null +++ b/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()) +} diff --git a/messages_text.go b/messages_text.go new file mode 100644 index 0000000..37a2863 --- /dev/null +++ b/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 + } +} diff --git a/state.go b/state.go new file mode 100644 index 0000000..509a39c --- /dev/null +++ b/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 + } + } +} diff --git a/ui/actions_list.go b/ui/actions_list.go deleted file mode 100644 index a22f493..0000000 --- a/ui/actions_list.go +++ /dev/null @@ -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) -} diff --git a/ui/application.go b/ui/application.go deleted file mode 100644 index 805d8c3..0000000 --- a/ui/application.go +++ /dev/null @@ -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() - } - } -} diff --git a/ui/builder.go b/ui/builder.go deleted file mode 100644 index 0075cf6..0000000 --- a/ui/builder.go +++ /dev/null @@ -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[-] ") - } -} diff --git a/ui/channels_tree.go b/ui/channels_tree.go deleted file mode 100644 index 1598ecf..0000000 --- a/ui/channels_tree.go +++ /dev/null @@ -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)) - } - } - } -} diff --git a/ui/guilds_tree.go b/ui/guilds_tree.go deleted file mode 100644 index d032f15..0000000 --- a/ui/guilds_tree.go +++ /dev/null @@ -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) -} diff --git a/ui/login_form.go b/ui/login_form.go deleted file mode 100644 index 12fba3d..0000000 --- a/ui/login_form.go +++ /dev/null @@ -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) -} diff --git a/ui/message_input.go b/ui/message_input.go deleted file mode 100644 index 9accb66..0000000 --- a/ui/message_input.go +++ /dev/null @@ -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 -} diff --git a/ui/messages_text.go b/ui/messages_text.go deleted file mode 100644 index 1adfd0f..0000000 --- a/ui/messages_text.go +++ /dev/null @@ -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 -} diff --git a/ui/util.go b/ui/util.go deleted file mode 100644 index a64f506..0000000 --- a/ui/util.go +++ /dev/null @@ -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 -} diff --git a/ui/view.go b/ui/view.go deleted file mode 100644 index 7d794e0..0000000 --- a/ui/view.go +++ /dev/null @@ -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 -}