This commit is contained in:
ayn2op 2023-01-09 17:09:21 +05:30 • committed by GitHub
commit 6683322bb1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1072 additions and 1641 deletions

View file

@ -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) }}

View file

@ -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)

View file

@ -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

161
config.go Normal file
View 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
}

View file

@ -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)
}

View file

@ -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
}

View 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)
}
}

16
go.mod
View file

@ -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
)

60
go.sum
View file

@ -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=

183
guilds_tree.go Normal file
View 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
View 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)
}

83
main.go
View file

@ -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)
}
}

137
message_input.go Normal file
View 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
View 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
View 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
}
}
}

View file

@ -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)
}

View file

@ -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()
}
}
}

View file

@ -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[-] ")
}
}

View file

@ -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))
}
}
}
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}