merged
This commit is contained in:
commit
8a96df64c5
24 changed files with 1045 additions and 1662 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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) }}
|
||||
|
|
|
|||
10
README.md
10
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
config.go
Normal file
161
config.go
Normal file
|
|
@ -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
|
||||
}
|
||||
139
config/config.go
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)
|
||||
}
|
||||
|
|
@ -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
discordmd/discordmd_test.go
Normal file
22
discordmd/discordmd_test.go
Normal file
|
|
@ -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
go.mod
8
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
|
||||
)
|
||||
|
|
|
|||
77
go.sum
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
guilds_tree.go
Normal file
183
guilds_tree.go
Normal file
|
|
@ -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
login_form.go
Normal file
81
login_form.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
84
main.go
84
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)
|
||||
}
|
||||
|
||||
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 +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
message_input.go
Normal file
137
message_input.go
Normal file
|
|
@ -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
messages_text.go
Normal file
289
messages_text.go
Normal file
|
|
@ -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
state.go
Normal file
76
state.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
231
ui/builder.go
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[-] ")
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
58
ui/util.go
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
|
||||
}
|
||||
42
ui/view.go
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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue