diff --git a/README.md b/README.md index ee0988b..89097a9 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ sudo mv ./discordo /usr/local/bin ### Configuration -A default configuration file is created on first start-up at `$HOME/.config/discordo/config.toml` on Unix, `$HOME/Library/Application Support/discordo/config.toml` on Darwin, and `%AppData%/discordo/config.toml` on Windows. You can configure the default configuration path using the `config` command-line flag. +A default configuration file is created on first start-up at `$HOME/.config/discordo/config.lua` on Unix, `$HOME/Library/Application Support/discordo/config.lua` on Darwin, and `%AppData%/discordo/config.lua` on Windows. You can configure the default configuration path using the `config` command-line flag. ## Disclaimer diff --git a/config/config.go b/config/config.go index 9c8cc0e..5d784d4 100644 --- a/config/config.go +++ b/config/config.go @@ -1,141 +1,77 @@ package config import ( - "log" + _ "embed" + "io" "os" "path/filepath" - "runtime" - "time" - "github.com/BurntSushi/toml" + lua "github.com/yuin/gopher-lua" ) -type IdentifyConfig struct { - UserAgent string `toml:"user_agent"` - Browser string `toml:"browser"` - BrowserVersion string `toml:"browser_version"` - Os string `toml:"os"` -} - -type KeysConfig struct { - ToggleGuildsTree string `toml:"toggle_guilds_tree"` - ToggleChannelsTree string `toml:"toggle_channels_tree"` - ToggleMessagesPanel string `toml:"toggle_messages_panel"` - ToggleMessageInput string `toml:"toggle_message_input"` - - OpenMessageActionsList string `toml:"open_message_actions_list"` - OpenExternalEditor string `toml:"open_external_editor"` - - SelectPreviousMessage string `toml:"select_previous_message"` - SelectNextMessage string `toml:"select_next_message"` - SelectFirstMessage string `toml:"select_first_message"` - SelectLastMessage string `toml:"select_last_message"` -} - -type ThemeConfig struct { - Background string `toml:"background"` - Border string `toml:"border"` - Title string `toml:"title"` -} +//go:embed config.lua +var LuaConfig []byte +// Config initializes a new Lua state, loads a configuration file, and defines essential micellaneous fields. type Config struct { - Mouse bool `toml:"mouse"` - Timestamps bool `toml:"timestamps"` - MessagesLimit uint `toml:"messages_limit"` - Timezone string `toml:"timezone"` - TimeFormat string `toml:"time_format"` - AttachmentDownloadsDir string `toml:"attachment_downloads_dir"` - Identify IdentifyConfig `toml:"identify"` - Theme ThemeConfig `toml:"theme"` - Keys KeysConfig `toml:"keys"` + // Path is the path of the configuration file. Its value is the configuration directory until Load() is called. + Path string + State *lua.LState } -func New() *Config { +func NewConfig(path string) *Config { return &Config{ - Mouse: true, - Timestamps: false, - MessagesLimit: 50, - Timezone: "Local", - TimeFormat: time.Stamp, - AttachmentDownloadsDir: UserDownloadsDir(), - Identify: IdentifyConfig{ - UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36", - Browser: "Chrome", - BrowserVersion: "104.0.5112.102", - Os: "Linux", - }, - Theme: ThemeConfig{ - Background: "black", - Border: "white", - Title: "white", - }, - Keys: KeysConfig{ - ToggleGuildsTree: "Rune[g]", - ToggleChannelsTree: "Rune[c]", - ToggleMessagesPanel: "Rune[m]", - ToggleMessageInput: "Rune[i]", - - OpenMessageActionsList: "Rune[a]", - OpenExternalEditor: "Ctrl+E", - - SelectPreviousMessage: "Up", - SelectNextMessage: "Down", - SelectFirstMessage: "Home", - SelectLastMessage: "End", - }, + Path: path, + State: lua.NewState(), } } -func (c *Config) Load(path string) error { +func (c *Config) Load() error { // Create directories that do not exist and are mentioned in the path recursively. - err := os.MkdirAll(filepath.Dir(path), os.ModePerm) + err := os.MkdirAll(c.Path, os.ModePerm) if err != nil { return err } - // If the configuration file does not exist already, create a new file; otherwise, open the existing file with read-write flag. - f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, os.ModePerm) + c.Path = filepath.Join(c.Path, "config.lua") + // Open the existing configuration file with read-only flag. + f, err := os.Open(c.Path) + // If the configuration file does not exist, create a new configuration file with the read-write flag. + if os.IsNotExist(err) { + f, err = os.Create(c.Path) + if err != nil { + return err + } + defer f.Close() + + _, err = f.Write(LuaConfig) + if err != nil { + return err + } + + return f.Sync() + } + if err != nil { return err } defer f.Close() - fi, err := f.Stat() + b, err := io.ReadAll(f) if err != nil { return err } - // If the file is empty (the size of the file is zero), write the default configuration to the file. - if fi.Size() == 0 { - return toml.NewEncoder(f).Encode(c) - } - - _, err = toml.NewDecoder(f).Decode(&c) - return err + LuaConfig = b + return nil } -func DefaultPath() string { - path, err := os.UserConfigDir() - if err != nil { - log.Fatal(err) - } +func (c *Config) KeyLua(s *lua.LState) int { + keyTable := s.NewTable() + keyTable.RawSetString("name", s.Get(1)) + keyTable.RawSetString("description", s.Get(2)) + keyTable.RawSetString("action", s.Get(3)) - path += "/discordo/config.toml" - return path -} - -func UserDownloadsDir() string { - // We try to set the download folder location to the default Downloads folder - var dlloc string - if runtime.GOOS == "windows" { - h, _ := os.UserHomeDir() - dlloc = h + "\\Downloads" - } else if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { - h, _ := os.UserHomeDir() - dlloc = h + "/Downloads" - } else { - dlloc = os.TempDir() // Very lame fallback, I know - } - - return dlloc + s.Push(keyTable) // Push the result + return 1 // Number of results } diff --git a/config/config.lua b/config/config.lua new file mode 100644 index 0000000..e40fd1c --- /dev/null +++ b/config/config.lua @@ -0,0 +1,127 @@ +local string = require "string" + +-- Whether the mouse is usable or not. +mouse = true + +-- The maximum number of messages to fetch and display on the messages panel. +-- Its value must not be lesser than 1 and greater than 100. +messagesLimit = 50 + +-- Whether to display the timestamp of the message beside the displayed message or not. +timestamps = false + +-- The timezone of the timestamps. +-- Learn more: https://pkg.go.dev/time#LoadLocation +timezone = "Local" + +-- 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 = "3:04PM" + +browser = "Chrome" +browserVersion = "104.0.5112.102" +oss = "Linux" + +-- Identify properties are connection properties that are dispatched in the IDENTIFY gateway event to trigger the initial handshake with the gateway. +-- Learn more: https://discord.com/developers/docs/topics/gateway#identify +identifyProperties = { + userAgent = string.format( + "Mozilla/5.0 (X11; %s x86_64) AppleWebKit/537.36 (KHTML, like Gecko) %s/%s Safari/537.36", + oss, + browser, + browserVersion + ), + browser = browser, + browserVersion = browserVersion, + os = oss +} + +-- Keybindings +keys = { + application = { + key( + "Rune[g]", + "Focus the guilds tree widget.", + function(core, event) + core.Application:SetFocus(core.GuildsTree) + end + ), + key( + "Rune[c]", + "Focus the channels tree widget.", + function(core, event) + core.Application:SetFocus(core.ChannelsTree) + end + ), + key( + "Rune[m]", + "Focus the messages panel widget.", + function(core, event) + core.Application:SetFocus(core.MessagesPanel) + end + ), + key( + "Rune[i]", + "Focus the message input widget.", + function(core, event) + core.Application:SetFocus(core.MessageInput) + end + ) + }, + messagesPanel = { + key( + "Rune[a]", + "Open the message actions list widget.", + function(core, event) + return openMessageActionsList() + end + ), + key( + "Up", + "Select the previous message.", + function(core, event) + return selectPreviousMessage() + end + ), + key( + "Down", + "Select the next message.", + function(core, event) + return selectNextMessage() + end + ), + key( + "Home", + "Select the first message.", + function(core, event) + return selectFirstMessage() + end + ), + key( + "End", + "Select the last message.", + function(core, event) + return selectLastMessage() + end + ) + }, + messageInput = { + key( + "Ctrl+E", + "Open the external editor.", + function() + return openExternalEditor() + end + ), + key( + "Ctrl+V", + "Paste the clipboard content.", + function() + return pasteClipboardContent() + end + ) + } +} + +-- Theme +theme = {background = "#1F2430", border = "white", title = "white"} diff --git a/go.mod b/go.mod index 80bbecc..3869555 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,15 @@ module github.com/ayntgl/discordo go 1.18 require ( - github.com/BurntSushi/toml v1.2.0 github.com/alecthomas/kong v0.6.1 github.com/atotto/clipboard v0.1.4 - github.com/diamondburned/arikawa/v3 v3.0.0 + github.com/diamondburned/arikawa/v3 v3.1.0 github.com/gdamore/tcell/v2 v2.5.3 github.com/rivo/tview v0.0.0-20220812085834-0e6b21a48e96 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 + github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64 github.com/zalando/go-keyring v0.2.1 + layeh.com/gopher-luar v1.0.10 ) require ( @@ -24,7 +25,7 @@ require ( github.com/mattn/go-runewidth v0.0.13 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.3.4 // indirect - golang.org/x/sys v0.0.0-20220823224334-20c2bfdbfe24 // indirect + golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 // indirect golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect diff --git a/go.sum b/go.sum index 8c16520..2e4a05f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0= -github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/alecthomas/kong v0.6.1 h1:1kNhcFepkR+HmasQpbiKDLylIL8yh5B5y1zPp5bJimA= github.com/alecthomas/kong v0.6.1/go.mod h1:JfHWDzLmbh/puW6I3V7uWenoh56YNVONW+w8eKeUr9I= github.com/alecthomas/repr v0.0.0-20210801044451-80ca428c5142 h1:8Uy0oSf5co/NZXje7U1z8Mpep++QJOldL2hs/sBQf48= @@ -8,6 +6,9 @@ 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/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 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= @@ -16,6 +17,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/diamondburned/arikawa/v3 v3.0.0 h1:VbdX1DtrBLE752IJftZHInVy6v8I3T8vhN9rKGvO6AY= github.com/diamondburned/arikawa/v3 v3.0.0/go.mod h1:5jBSNnp82Z/EhsKa6Wk9FsOqSxfVkNZDTDBPOj47LpY= +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= @@ -49,17 +52,21 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ= +github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64 h1:5mLPGnFdSsevFRFc9q3yYbBkB6tsm4aCwwQV/j1JQAQ= +github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= 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-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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.0.0-20220823224334-20c2bfdbfe24 h1:TyKJRhyo17yWxOMCTHKWrc5rddHORMlnZ/j57umaUd8= -golang.org/x/sys v0.0.0-20220823224334-20c2bfdbfe24/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64 h1:UiNENfZ8gDvpiWw7IpOMQ27spWmThO1RwwdQVbJahJM= +golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/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.0.0-20220722155259-a9ba230a4035 h1:Q5284mrmYTpACcm+eAKjKJH48BBwSyfJqmmGDTtT8Vc= @@ -78,3 +85,5 @@ 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= +layeh.com/gopher-luar v1.0.10 h1:55b0mpBhN9XSshEd2Nz6WsbYXctyBT35azk4POQNSXo= +layeh.com/gopher-luar v1.0.10/go.mod h1:TPnIVCZ2RJBndm7ohXyaqfhzjlZ+OA2SZR/YwL8tECk= diff --git a/main.go b/main.go index 79633a4..991374d 100644 --- a/main.go +++ b/main.go @@ -2,12 +2,14 @@ package main import ( "log" + "os" + "path/filepath" "github.com/alecthomas/kong" - "github.com/ayntgl/discordo/config" "github.com/ayntgl/discordo/ui" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + lua "github.com/yuin/gopher-lua" "github.com/zalando/go-keyring" ) @@ -18,7 +20,7 @@ const ( var cli struct { Token string `help:"The authentication token."` - Config string `help:"The path of the configuration file." type:"path"` + Config string `help:"The path to the configuration directory." type:"path"` } func main() { @@ -31,30 +33,28 @@ func main() { // Defaults if cli.Config == "" { - cli.Config = config.DefaultPath() + path, err := os.UserConfigDir() + if err != nil { + log.Fatal(err) + } + + cli.Config = filepath.Join(path, name) } if cli.Token == "" { cli.Token, _ = keyring.Get(name, "token") } - c := config.New() - err := c.Load(cli.Config) - if err != nil { - log.Fatal(err) - } - - app := ui.NewApp(cli.Token, c) + c := ui.NewCore(cli.Config) if cli.Token != "" { - err := app.Connect() + err := c.Run(cli.Token) if err != nil { log.Fatal(err) } - app.DrawMainFlex() - - app.SetRoot(app.MainFlex, true) - app.SetFocus(app.GuildsTree) + c.DrawMainFlex() + c.Application.SetRoot(c.MainFlex, true) + c.Application.SetFocus(c.GuildsTree) } else { loginForm := ui.NewLoginForm(false) loginForm.AddButton("Login", func() { @@ -65,21 +65,21 @@ func main() { } // Login using the email and password only - lr, err := app.State.Login(email, password) + lr, err := c.State.Login(email, password) if err != nil { log.Fatal(err) } if lr.Token != "" && !lr.MFA { - app.State.Token = lr.Token - err = app.Connect() + err = c.Run(lr.Token) if err != nil { log.Fatal(err) } - app.DrawMainFlex() - app.SetRoot(app.MainFlex, true) - app.SetFocus(app.GuildsTree) + c.DrawMainFlex() + c.Application.SetRoot(c.MainFlex, true) + c.Application.SetFocus(c.GuildsTree) + go keyring.Set(name, "token", lr.Token) } else { // The account has MFA enabled, reattempt login with MFA code and ticket. @@ -90,29 +90,28 @@ func main() { return } - lr, err = app.State.TOTP(code, lr.Ticket) + lr, err = c.State.TOTP(code, lr.Ticket) if err != nil { log.Fatal(err) } - app.State.Token = lr.Token - err = app.Connect() + err = c.Run(lr.Token) if err != nil { log.Fatal(err) } - app.DrawMainFlex() - app.SetRoot(app.MainFlex, true) - app.SetFocus(app.GuildsTree) + c.DrawMainFlex() + c.Application.SetRoot(c.MainFlex, true) + c.Application.SetFocus(c.GuildsTree) go keyring.Set(name, "token", lr.Token) }) - app.SetRoot(mfaLoginForm, true) + c.Application.SetRoot(mfaLoginForm, true) } }) - app.SetRoot(loginForm, true) + c.Application.SetRoot(loginForm, true) } tview.Borders.TopLeftFocus = tview.Borders.TopLeft @@ -128,11 +127,20 @@ func main() { tview.Borders.Horizontal = 0 tview.Borders.Vertical = 0 - tview.Styles.PrimitiveBackgroundColor = tcell.GetColor(app.Config.Theme.Background) - tview.Styles.BorderColor = tcell.GetColor(app.Config.Theme.Border) - tview.Styles.TitleColor = tcell.GetColor(app.Config.Theme.Title) + themeTable, ok := c.Config.State.GetGlobal("theme").(*lua.LTable) + if !ok { + return + } - err = app.Run() + background := themeTable.RawGetString("background") + border := themeTable.RawGetString("border") + title := themeTable.RawGetString("title") + + tview.Styles.PrimitiveBackgroundColor = tcell.GetColor(lua.LVAsString(background)) + tview.Styles.BorderColor = tcell.GetColor(lua.LVAsString(border)) + tview.Styles.TitleColor = tcell.GetColor(lua.LVAsString(title)) + + err := c.Application.Run() if err != nil { log.Fatal(err) } diff --git a/ui/app.go b/ui/app.go deleted file mode 100644 index cf5302d..0000000 --- a/ui/app.go +++ /dev/null @@ -1,205 +0,0 @@ -package ui - -import ( - "context" - "strings" - - "github.com/ayntgl/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" -) - -type App struct { - *tview.Application - MainFlex *tview.Flex - GuildsTree *GuildsTree - ChannelsTree *ChannelsTree - MessagesPanel *MessagesPanel - MessageInput *MessageInput - - Config *config.Config - State *state.State -} - -func NewApp(token string, c *config.Config) *App { - app := &App{ - Application: tview.NewApplication(), - MainFlex: tview.NewFlex(), - Config: c, - - State: state.NewWithIdentifier(gateway.NewIdentifier(gateway.IdentifyCommand{ - Token: token, - Intents: nil, - Properties: gateway.IdentifyProperties{ - Browser: c.Identify.Browser, - BrowserUserAgent: c.Identify.UserAgent, - BrowserVersion: c.Identify.BrowserVersion, - OS: c.Identify.Os, - }, - // The official client sets the compress field as false. - Compress: false, - })), - } - - app.GuildsTree = NewGuildsTree(app) - app.ChannelsTree = NewChannelsTree(app) - app.MessagesPanel = NewMessagesPanel(app) - app.MessageInput = NewMessageInput(app) - app.EnableMouse(app.Config.Mouse) - app.MainFlex.SetInputCapture(app.onInputCapture) - - return app -} - -func (app *App) Connect() error { - // For user accounts, all of the guilds, the user is in, are dispatched in the READY gateway event. - // Whereas, for bot accounts, the guilds are dispatched discretely in the GUILD_CREATE gateway events. - if !strings.HasPrefix(app.State.Token, "Bot") { - api.UserAgent = app.Config.Identify.UserAgent - app.State.AddHandler(app.onStateReady) - } - - app.State.AddHandler(app.onStateGuildCreate) - app.State.AddHandler(app.onStateGuildDelete) - app.State.AddHandler(app.onStateMessageCreate) - return app.State.Open(context.Background()) -} - -func (app *App) onInputCapture(e *tcell.EventKey) *tcell.EventKey { - if app.MessageInput.HasFocus() { - return e - } - - if app.MainFlex.GetItemCount() != 0 { - switch e.Name() { - case app.Config.Keys.ToggleGuildsTree: - app.SetFocus(app.GuildsTree) - return nil - case app.Config.Keys.ToggleChannelsTree: - app.SetFocus(app.ChannelsTree) - return nil - case app.Config.Keys.ToggleMessagesPanel: - app.SetFocus(app.MessagesPanel) - return nil - case app.Config.Keys.ToggleMessageInput: - app.SetFocus(app.MessageInput) - return nil - } - } - - return e -} - -func (app *App) DrawMainFlex() { - leftFlex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(app.GuildsTree, 10, 1, false). - AddItem(app.ChannelsTree, 0, 1, false) - rightFlex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(app.MessagesPanel, 0, 1, false). - AddItem(app.MessageInput, 3, 1, false) - app.MainFlex. - AddItem(leftFlex, 0, 1, false). - AddItem(rightFlex, 0, 4, false) -} - -func (app *App) onStateReady(r *gateway.ReadyEvent) { - rootNode := app.GuildsTree.GetRoot() - for _, gf := range r.UserSettings.GuildFolders { - if gf.ID == 0 { - for _, gID := range gf.GuildIDs { - g, err := app.State.Cabinet.Guild(gID) - if err != nil { - return - } - - guildNode := tview.NewTreeNode(g.Name) - guildNode.SetReference(g.ID) - rootNode.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()) - rootNode.AddChild(folderNode) - - for _, gID := range gf.GuildIDs { - g, err := app.State.Cabinet.Guild(gID) - if err != nil { - return - } - - guildNode := tview.NewTreeNode(g.Name) - guildNode.SetReference(g.ID) - folderNode.AddChild(guildNode) - } - } - - } - - app.GuildsTree.SetCurrentNode(rootNode) - app.SetFocus(app.GuildsTree) -} - -func (app *App) onStateGuildCreate(g *gateway.GuildCreateEvent) { - guildNode := tview.NewTreeNode(g.Name) - guildNode.SetReference(g.ID) - - rootNode := app.GuildsTree.GetRoot() - rootNode.AddChild(guildNode) - - app.Draw() -} - -func (app *App) onStateGuildDelete(g *gateway.GuildDeleteEvent) { - rootNode := app.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) - } - - app.Draw() -} - -func (app *App) onStateMessageCreate(m *gateway.MessageCreateEvent) { - if app.ChannelsTree.SelectedChannel != nil && app.ChannelsTree.SelectedChannel.ID == m.ChannelID { - _, err := app.MessagesPanel.Write(buildMessage(app, m.Message)) - if err != nil { - return - } - - if len(app.MessagesPanel.GetHighlights()) == 0 { - app.MessagesPanel.ScrollToEnd() - } - } -} diff --git a/ui/builder.go b/ui/builder.go index 3570830..dec176c 100644 --- a/ui/builder.go +++ b/ui/builder.go @@ -6,9 +6,10 @@ import ( "time" "github.com/diamondburned/arikawa/v3/discord" + lua "github.com/yuin/gopher-lua" ) -func buildMessage(app *App, m discord.Message) []byte { +func buildMessage(c *Core, m discord.Message) []byte { var b strings.Builder switch m.Type { @@ -20,25 +21,30 @@ func buildMessage(app *App, m discord.Message) []byte { b.WriteString(m.ID.String()) b.WriteString("\"]") // Build the message associated with crosspost, channel follow add, pin, or a reply. - buildReferencedMessage(&b, m.ReferencedMessage, app.State.Ready().User.ID) + buildReferencedMessage(&b, m.ReferencedMessage, c.State.Ready().User.ID) - if app.Config.Timestamps { - loc, err := time.LoadLocation(app.Config.Timezone) + timestamps := c.Config.State.GetGlobal("timestamps") + + if lua.LVAsBool(timestamps) { + timezone := c.Config.State.GetGlobal("timezone") + loc, err := time.LoadLocation(lua.LVAsString(timezone)) if err != nil { return nil } + timeFormat := c.Config.State.GetGlobal("timeFormat") + b.WriteString("[::d]") - b.WriteString(m.Timestamp.Time().In(loc).Format(app.Config.TimeFormat)) + b.WriteString(m.Timestamp.Time().In(loc).Format(lua.LVAsString(timeFormat))) b.WriteString("[::-]") b.WriteByte(' ') } // Build the author of this message. - buildAuthor(&b, m.Author, app.State.Ready().User.ID) + buildAuthor(&b, m.Author, c.State.Ready().User.ID) // Build the contents of the message. - buildContent(&b, m, app.State.Ready().User.ID) + buildContent(&b, m, c.State.Ready().User.ID) if m.EditedTimestamp.IsValid() { b.WriteString(" [::d](edited)[::-]") diff --git a/ui/channels_tree.go b/ui/channels_tree.go index 4398cdf..414e292 100644 --- a/ui/channels_tree.go +++ b/ui/channels_tree.go @@ -5,19 +5,20 @@ import ( "github.com/diamondburned/arikawa/v3/discord" "github.com/rivo/tview" + lua "github.com/yuin/gopher-lua" ) type ChannelsTree struct { *tview.TreeView - app *App - SelectedChannel *discord.Channel + + core *Core } -func NewChannelsTree(app *App) *ChannelsTree { +func NewChannelsTree(c *Core) *ChannelsTree { ct := &ChannelsTree{ TreeView: tview.NewTreeView(), - app: app, + core: c, } ct.SetRoot(tview.NewTreeNode("")) @@ -34,15 +35,15 @@ func NewChannelsTree(app *App) *ChannelsTree { func (ct *ChannelsTree) onSelected(node *tview.TreeNode) { ct.SelectedChannel = nil - ct.app.MessagesPanel.SelectedMessage = -1 - ct.app.MessagesPanel. + ct.core.MessagesPanel.SelectedMessage = -1 + ct.core.MessagesPanel. Highlight(). Clear(). SetTitle("") - ct.app.MessageInput.SetText("") + ct.core.MessageInput.SetText("") ref := node.GetReference() - c, err := ct.app.State.Cabinet.Channel(ref.(discord.ChannelID)) + c, err := ct.core.State.Cabinet.Channel(ref.(discord.ChannelID)) if err != nil { return } @@ -54,29 +55,30 @@ func (ct *ChannelsTree) onSelected(node *tview.TreeNode) { } ct.SelectedChannel = c - ct.app.SetFocus(ct.app.MessageInput) + ct.core.Application.SetFocus(ct.core.MessageInput) title := channelToString(*c) if c.Topic != "" { title += " - " + parseMarkdown(c.Topic) } - ct.app.MessagesPanel.SetTitle(title) + ct.core.MessagesPanel.SetTitle(title) go func() { + messagesLimit := ct.core.Config.State.GetGlobal("messagesLimit") // The returned slice will be sorted from latest to oldest. - ms, err := ct.app.State.Messages(c.ID, ct.app.Config.MessagesLimit) + ms, err := ct.core.State.Messages(c.ID, uint(lua.LVAsNumber(messagesLimit))) if err != nil { return } for i := len(ms) - 1; i >= 0; i-- { - _, err = ct.app.MessagesPanel.Write(buildMessage(ct.app, ms[i])) + _, err = ct.core.MessagesPanel.Write(buildMessage(ct.core, ms[i])) if err != nil { return } } - ct.app.MessagesPanel.ScrollToEnd() + ct.core.MessagesPanel.ScrollToEnd() }() } @@ -88,7 +90,7 @@ func (ct *ChannelsTree) createChannelNode(c discord.Channel) *tview.TreeNode { } func (ct *ChannelsTree) createPrivateChannelNodes(rootNode *tview.TreeNode) { - cs, err := ct.app.State.Cabinet.PrivateChannels() + cs, err := ct.core.State.Cabinet.PrivateChannels() if err != nil { return } @@ -103,7 +105,7 @@ func (ct *ChannelsTree) createPrivateChannelNodes(rootNode *tview.TreeNode) { } func (ct *ChannelsTree) createGuildChannelNodes(rootNode *tview.TreeNode, gID discord.GuildID) { - cs, err := ct.app.State.Cabinet.Channels(gID) + cs, err := ct.core.State.Cabinet.Channels(gID) if err != nil { return } diff --git a/ui/core.go b/ui/core.go new file mode 100644 index 0000000..eed200e --- /dev/null +++ b/ui/core.go @@ -0,0 +1,261 @@ +package ui + +import ( + "context" + "strings" + + "github.com/ayntgl/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" + lua "github.com/yuin/gopher-lua" + luar "layeh.com/gopher-luar" +) + +// Core is responsible for the following: +// - Initialization of the application, UI elements, configuration, and state. +// - Configuration of the application and state when Run is called. +// - Management of the application and state. +type Core struct { + Application *tview.Application + MainFlex *tview.Flex + GuildsTree *GuildsTree + ChannelsTree *ChannelsTree + MessagesPanel *MessagesPanel + MessageInput *MessageInput + + Config *config.Config + State *state.State +} + +func NewCore(path string) *Core { + c := &Core{ + Application: tview.NewApplication(), + MainFlex: tview.NewFlex(), + + Config: config.NewConfig(path), + } + + c.MainFlex.SetInputCapture(c.onInputCapture) + c.GuildsTree = NewGuildsTree(c) + c.ChannelsTree = NewChannelsTree(c) + c.MessagesPanel = NewMessagesPanel(c) + c.MessageInput = NewMessageInput(c) + return c +} + +func (c *Core) Run(token string) error { + err := c.Config.Load() + if err != nil { + return err + } + + c.register() + err = c.Config.State.DoString(string(config.LuaConfig)) + if err != nil { + return err + } + + c.Application.EnableMouse(lua.LVAsBool(c.Config.State.GetGlobal("mouse"))) + + identifyProperties, ok := c.Config.State.GetGlobal("identifyProperties").(*lua.LTable) + if !ok { + identifyProperties = c.Config.State.NewTable() + } + + userAgent := lua.LVAsString(identifyProperties.RawGetString("userAgent")) + + c.State = state.NewWithIdentifier(gateway.NewIdentifier(gateway.IdentifyCommand{ + Token: token, + Intents: nil, + Properties: gateway.IdentifyProperties{ + Browser: lua.LVAsString(identifyProperties.RawGetString("browser")), + BrowserVersion: lua.LVAsString(identifyProperties.RawGetString("browserVersion")), + BrowserUserAgent: userAgent, + OS: lua.LVAsString(identifyProperties.RawGetString("os")), + }, + // The official client sets the compress field as false. + Compress: false, + })) + + // For user accounts, all of the guilds, the user is in, are dispatched in the READY gateway event. + // Whereas, for bot accounts, the guilds are dispatched discretely in the GUILD_CREATE gateway events. + if !strings.HasPrefix(c.State.Token, "Bot") { + api.UserAgent = userAgent + c.State.AddHandler(c.onStateReady) + } + + c.State.AddHandler(c.onStateGuildCreate) + c.State.AddHandler(c.onStateGuildDelete) + c.State.AddHandler(c.onStateMessageCreate) + return c.State.Open(context.Background()) +} + +func (c *Core) register() { + c.Config.State.SetGlobal("key", c.Config.State.NewFunction(c.Config.KeyLua)) + // Messages panel + c.Config.State.SetGlobal("openMessageActionsList", c.Config.State.NewFunction(c.MessagesPanel.openMessageActionsListLua)) + c.Config.State.SetGlobal("selectPreviousMessage", c.Config.State.NewFunction(c.MessagesPanel.selectPreviousMessageLua)) + c.Config.State.SetGlobal("selectNextMessage", c.Config.State.NewFunction(c.MessagesPanel.selectNextMessageLua)) + c.Config.State.SetGlobal("selectFirstMessage", c.Config.State.NewFunction(c.MessagesPanel.selectFirstMessageLua)) + c.Config.State.SetGlobal("selectLastMessage", c.Config.State.NewFunction(c.MessagesPanel.selectLastMessageLua)) + // Message input + c.Config.State.SetGlobal("openExternalEditor", c.Config.State.NewFunction(c.MessageInput.openExternalEditorLua)) + c.Config.State.SetGlobal("pasteClipboardContent", c.Config.State.NewFunction(c.MessageInput.pasteClipboardContentLua)) +} + +func (c *Core) DrawMainFlex() { + leftFlex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(c.GuildsTree, 10, 1, false). + AddItem(c.ChannelsTree, 0, 1, false) + rightFlex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(c.MessagesPanel, 0, 1, false). + AddItem(c.MessageInput, 3, 1, false) + c.MainFlex. + AddItem(leftFlex, 0, 1, false). + AddItem(rightFlex, 0, 4, false) +} + +func (c *Core) onInputCapture(e *tcell.EventKey) *tcell.EventKey { + if c.MessageInput.HasFocus() { + return e + } + // If the main flex is nil, that is, it is not initialized yet, then the login form is currently focused. + if c.MainFlex == nil { + return e + } + + keysTable, ok := c.Config.State.GetGlobal("keys").(*lua.LTable) + if !ok { + return e + } + + applicationTable, ok := keysTable.RawGetString("application").(*lua.LTable) + if !ok { + return e + } + + var fn lua.LValue + applicationTable.ForEach(func(k, v lua.LValue) { + keyTable := v.(*lua.LTable) + if e.Name() == lua.LVAsString(keyTable.RawGetString("name")) { + fn = keyTable.RawGetString("action") + } + }) + + c.Config.State.CallByParam(lua.P{ + Fn: fn, + NRet: 1, + Protect: true, + }, luar.New(c.Config.State, c), luar.New(c.Config.State, e)) + // Returned value + ret, ok := c.Config.State.Get(-1).(*lua.LUserData) + if !ok { + return e + } + + // Remove returned value + c.Config.State.Pop(1) + return ret.Value.(*tcell.EventKey) +} + +func (c *Core) onStateReady(r *gateway.ReadyEvent) { + rootNode := c.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 { + return + } + + guildNode := tview.NewTreeNode(g.Name) + guildNode.SetReference(g.ID) + rootNode.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()) + rootNode.AddChild(folderNode) + + for _, gID := range gf.GuildIDs { + g, err := c.State.Cabinet.Guild(gID) + if err != nil { + return + } + + guildNode := tview.NewTreeNode(g.Name) + guildNode.SetReference(g.ID) + folderNode.AddChild(guildNode) + } + } + + } + + c.GuildsTree.SetCurrentNode(rootNode) + c.Application.SetFocus(c.GuildsTree) +} + +func (c *Core) onStateGuildCreate(g *gateway.GuildCreateEvent) { + guildNode := tview.NewTreeNode(g.Name) + guildNode.SetReference(g.ID) + + rootNode := c.GuildsTree.GetRoot() + rootNode.AddChild(guildNode) + + c.Application.Draw() +} + +func (c *Core) onStateGuildDelete(g *gateway.GuildDeleteEvent) { + rootNode := c.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.Application.Draw() +} + +func (c *Core) onStateMessageCreate(m *gateway.MessageCreateEvent) { + if c.ChannelsTree.SelectedChannel != nil && c.ChannelsTree.SelectedChannel.ID == m.ChannelID { + _, err := c.MessagesPanel.Write(buildMessage(c, m.Message)) + if err != nil { + return + } + + if len(c.MessagesPanel.GetHighlights()) == 0 { + c.MessagesPanel.ScrollToEnd() + } + } +} diff --git a/ui/guilds_tree.go b/ui/guilds_tree.go index 1d42b9d..3e5c490 100644 --- a/ui/guilds_tree.go +++ b/ui/guilds_tree.go @@ -7,13 +7,13 @@ import ( type GuildsTree struct { *tview.TreeView - app *App + core *Core } -func NewGuildsTree(app *App) *GuildsTree { +func NewGuildsTree(c *Core) *GuildsTree { gt := &GuildsTree{ TreeView: tview.NewTreeView(), - app: app, + core: c, } rootNode := tview.NewTreeNode("") @@ -32,15 +32,15 @@ func NewGuildsTree(app *App) *GuildsTree { } func (gt *GuildsTree) onSelected(node *tview.TreeNode) { - gt.app.ChannelsTree.SelectedChannel = nil - gt.app.MessagesPanel.SelectedMessage = -1 - rootNode := gt.app.ChannelsTree.GetRoot() + gt.core.ChannelsTree.SelectedChannel = nil + gt.core.MessagesPanel.SelectedMessage = -1 + rootNode := gt.core.ChannelsTree.GetRoot() rootNode.ClearChildren() - gt.app.MessagesPanel. + gt.core.MessagesPanel. Highlight(). Clear(). SetTitle("") - gt.app.MessageInput.SetText("") + gt.core.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 { @@ -51,11 +51,11 @@ func (gt *GuildsTree) onSelected(node *tview.TreeNode) { ref := node.GetReference() // If the reference of the selected node is nil, it must be the direct messages node. if ref == nil { - gt.app.ChannelsTree.createPrivateChannelNodes(rootNode) + gt.core.ChannelsTree.createPrivateChannelNodes(rootNode) } else { // Guild - gt.app.ChannelsTree.createGuildChannelNodes(rootNode, ref.(discord.GuildID)) + gt.core.ChannelsTree.createGuildChannelNodes(rootNode, ref.(discord.GuildID)) } - gt.app.ChannelsTree.SetCurrentNode(rootNode) - gt.app.SetFocus(gt.app.ChannelsTree) + gt.core.ChannelsTree.SetCurrentNode(rootNode) + gt.core.Application.SetFocus(gt.core.ChannelsTree) } diff --git a/ui/message_actions_list.go b/ui/message_actions_list.go index 712f83b..71cb047 100644 --- a/ui/message_actions_list.go +++ b/ui/message_actions_list.go @@ -17,26 +17,25 @@ var linkRegex = regexp.MustCompile("https?://.+") type MessageActionsList struct { *tview.List - app *App + core *Core message *discord.Message } -func NewMessageActionsList(app *App, m *discord.Message) *MessageActionsList { +func NewMessageActionsList(c *Core, m *discord.Message) *MessageActionsList { mal := &MessageActionsList{ List: tview.NewList(), - app: app, + core: c, message: m, } mal.ShowSecondaryText(false) mal.SetDoneFunc(func() { - app. - SetRoot(app.MainFlex, true). - SetFocus(app.MessagesPanel) + c.Application.SetRoot(c.MainFlex, true) + c.Application.SetFocus(c.MessagesPanel) }) // If the client user has the `SEND_MESSAGES` permission, add "Reply" and "Mention Reply" actions. - if hasPermission(app.State, app.ChannelsTree.SelectedChannel.ID, discord.PermissionSendMessages) { + if hasPermission(c.State, c.ChannelsTree.SelectedChannel.ID, discord.PermissionSendMessages) { mal.AddItem("Reply", "", 'r', mal.replyAction) mal.AddItem("Mention Reply", "", 'R', mal.mentionReplyAction) } @@ -54,8 +53,8 @@ func NewMessageActionsList(app *App, m *discord.Message) *MessageActionsList { go open.Run(l) } - app.SetRoot(app.MainFlex, true) - app.SetFocus(app.MessagesPanel) + c.Application.SetRoot(c.MainFlex, true) + c.Application.SetFocus(c.MessagesPanel) }) } @@ -66,7 +65,7 @@ func NewMessageActionsList(app *App, m *discord.Message) *MessageActionsList { } // If the client user has the `MANAGE_MESSAGES` permission, add a new action to delete the message. - if hasPermission(app.State, app.ChannelsTree.SelectedChannel.ID, discord.PermissionManageMessages) { + if hasPermission(c.State, c.ChannelsTree.SelectedChannel.ID, discord.PermissionManageMessages) { mal.AddItem("Delete", "", 'd', mal.deleteAction) } @@ -82,35 +81,32 @@ func NewMessageActionsList(app *App, m *discord.Message) *MessageActionsList { } func (mal *MessageActionsList) replyAction() { - mal.app.MessageInput.SetTitle("Replying to " + mal.message.Author.Tag()) + mal.core.MessageInput.SetTitle("Replying to " + mal.message.Author.Tag()) - mal.app. - SetRoot(mal.app.MainFlex, true). - SetFocus(mal.app.MessageInput) + mal.core.Application.SetRoot(mal.core.MainFlex, true) + mal.core.Application.SetFocus(mal.core.MessageInput) } func (mal *MessageActionsList) mentionReplyAction() { - mal.app.MessageInput.SetTitle("[@] Replying to " + mal.message.Author.Tag()) + mal.core.MessageInput.SetTitle("[@] Replying to " + mal.message.Author.Tag()) - mal.app. - SetRoot(mal.app.MainFlex, true). - SetFocus(mal.app.MessageInput) + mal.core.Application.SetRoot(mal.core.MainFlex, true) + mal.core.Application.SetFocus(mal.core.MessageInput) } func (mal *MessageActionsList) selectReplyAction() { - ms, err := mal.app.State.Cabinet.Messages(mal.message.ChannelID) + ms, err := mal.core.State.Cabinet.Messages(mal.message.ChannelID) if err != nil { return } - mal.app.MessagesPanel.SelectedMessage, _ = findMessageByID(ms, mal.message.ReferencedMessage.ID) - mal.app.MessagesPanel. + mal.core.MessagesPanel.SelectedMessage, _ = findMessageByID(ms, mal.message.ReferencedMessage.ID) + mal.core.MessagesPanel. Highlight(mal.message.ReferencedMessage.ID.String()). ScrollToHighlight() - mal.app. - SetRoot(mal.app.MainFlex, true). - SetFocus(mal.app.MessagesPanel) + mal.core.Application.SetRoot(mal.core.MainFlex, true) + mal.core.Application.SetFocus(mal.core.MessagesPanel) } func (mal *MessageActionsList) openAttachmentAction() { @@ -136,14 +132,19 @@ func (mal *MessageActionsList) openAttachmentAction() { go open.Run(f.Name()) } - mal.app. - SetRoot(mal.app.MainFlex, true). - SetFocus(mal.app.MessagesPanel) + mal.core.Application.SetRoot(mal.core.MainFlex, true) + mal.core.Application.SetFocus(mal.core.MessagesPanel) } func (mal *MessageActionsList) downloadAttachmentAction() { for _, a := range mal.message.Attachments { - f, err := os.Create(filepath.Join(mal.app.Config.AttachmentDownloadsDir, a.Filename)) + 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 } @@ -162,40 +163,38 @@ func (mal *MessageActionsList) downloadAttachmentAction() { f.Write(d) } - mal.app. - SetRoot(mal.app.MainFlex, true). - SetFocus(mal.app.MessagesPanel) + mal.core.Application.SetRoot(mal.core.MainFlex, true) + mal.core.Application.SetFocus(mal.core.MessagesPanel) } func (mal *MessageActionsList) deleteAction() { - mal.app.MessagesPanel.Clear() + mal.core.MessagesPanel.Clear() - err := mal.app.State.MessageRemove(mal.message.ChannelID, mal.message.ID) + err := mal.core.State.MessageRemove(mal.message.ChannelID, mal.message.ID) if err != nil { return } - err = mal.app.State.DeleteMessage(mal.message.ChannelID, mal.message.ID, "Unknown") + err = mal.core.State.DeleteMessage(mal.message.ChannelID, mal.message.ID, "Unknown") if err != nil { return } // The returned slice will be sorted from latest to oldest. - ms, err := mal.app.State.Cabinet.Messages(mal.message.ChannelID) + ms, err := mal.core.State.Cabinet.Messages(mal.message.ChannelID) if err != nil { return } for i := len(ms) - 1; i >= 0; i-- { - _, err = mal.app.MessagesPanel.Write(buildMessage(mal.app, ms[i])) + _, err = mal.core.MessagesPanel.Write(buildMessage(mal.core, ms[i])) if err != nil { return } } - mal.app. - SetRoot(mal.app.MainFlex, true). - SetFocus(mal.app.MessagesPanel) + mal.core.Application.SetRoot(mal.core.MainFlex, true) + mal.core.Application.SetFocus(mal.core.MessagesPanel) } func (mal *MessageActionsList) copyContentAction() { @@ -204,8 +203,8 @@ func (mal *MessageActionsList) copyContentAction() { return } - mal.app.SetRoot(mal.app.MainFlex, true) - mal.app.SetFocus(mal.app.MessagesPanel) + mal.core.Application.SetRoot(mal.core.MainFlex, true) + mal.core.Application.SetFocus(mal.core.MessagesPanel) } func (mal *MessageActionsList) copyIDAction() { @@ -214,6 +213,6 @@ func (mal *MessageActionsList) copyIDAction() { return } - mal.app.SetRoot(mal.app.MainFlex, true) - mal.app.SetFocus(mal.app.MessagesPanel) + mal.core.Application.SetRoot(mal.core.MainFlex, true) + mal.core.Application.SetFocus(mal.core.MessagesPanel) } diff --git a/ui/message_input.go b/ui/message_input.go index e9189e9..836c62f 100644 --- a/ui/message_input.go +++ b/ui/message_input.go @@ -12,17 +12,19 @@ import ( "github.com/diamondburned/arikawa/v3/utils/json/option" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + lua "github.com/yuin/gopher-lua" + luar "layeh.com/gopher-luar" ) type MessageInput struct { *tview.InputField - app *App + core *Core } -func NewMessageInput(app *App) *MessageInput { +func NewMessageInput(c *Core) *MessageInput { mi := &MessageInput{ InputField: tview.NewInputField(), - app: app, + core: c, } mi.SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor) @@ -38,29 +40,65 @@ func NewMessageInput(app *App) *MessageInput { } func (mi *MessageInput) onInputCapture(e *tcell.EventKey) *tcell.EventKey { + keysTable, ok := mi.core.Config.State.GetGlobal("keys").(*lua.LTable) + if !ok { + return e + } + + messageInputTable, ok := keysTable.RawGetString("messageInput").(*lua.LTable) + if !ok { + return e + } + + var fn lua.LValue + messageInputTable.ForEach(func(k, v lua.LValue) { + keyTable := v.(*lua.LTable) + if e.Name() == lua.LVAsString(keyTable.RawGetString("name")) { + fn = keyTable.RawGetString("action") + } + }) + + if fn != nil { + mi.core.Config.State.CallByParam(lua.P{ + Fn: fn, + NRet: 1, + Protect: true, + }, luar.New(mi.core.Config.State, mi.core), luar.New(mi.core.Config.State, e)) + // Returned value + ret, ok := mi.core.Config.State.Get(-1).(*lua.LUserData) + if !ok { + return e + } + + // Remove returned value + mi.core.Config.State.Pop(1) + + ev, ok := ret.Value.(*tcell.EventKey) + if ok { + return ev + } + } + + // Defaults switch e.Name() { case "Enter": return mi.sendMessage() - case "Ctrl+V": - return mi.pasteFromClipboard() case "Esc": mi. SetText(""). SetTitle("") - mi.app.SetFocus(mi.app.MainFlex) + mi.core.Application.SetFocus(mi.core.MainFlex) - mi.app.MessagesPanel.SelectedMessage = -1 - mi.app.MessagesPanel.Highlight() + mi.core.MessagesPanel.SelectedMessage = -1 + mi.core.MessagesPanel.Highlight() return nil - case mi.app.Config.Keys.OpenExternalEditor: - return mi.openExternalEditor() } return e } func (mi *MessageInput) sendMessage() *tcell.EventKey { - if mi.app.ChannelsTree.SelectedChannel == nil { + if mi.core.ChannelsTree.SelectedChannel == nil { return nil } @@ -69,13 +107,14 @@ func (mi *MessageInput) sendMessage() *tcell.EventKey { return nil } - ms, err := mi.app.State.Messages(mi.app.ChannelsTree.SelectedChannel.ID, mi.app.Config.MessagesLimit) + messagesLimit := mi.core.Config.State.GetGlobal("messagesLimit") + ms, err := mi.core.State.Messages(mi.core.ChannelsTree.SelectedChannel.ID, uint(lua.LVAsNumber(messagesLimit))) if err != nil { return nil } - if len(mi.app.MessagesPanel.GetHighlights()) != 0 { - mID, err := discord.ParseSnowflake(mi.app.MessagesPanel.GetHighlights()[0]) + if len(mi.core.MessagesPanel.GetHighlights()) != 0 { + mID, err := discord.ParseSnowflake(mi.core.MessagesPanel.GetHighlights()[0]) if err != nil { return nil } @@ -92,36 +131,36 @@ func (mi *MessageInput) sendMessage() *tcell.EventKey { d.AllowedMentions.RepliedUser = option.True } - go mi.app.State.SendMessageComplex(m.ChannelID, d) + go mi.core.State.SendMessageComplex(m.ChannelID, d) - mi.app.MessagesPanel.SelectedMessage = -1 - mi.app.MessagesPanel.Highlight() + mi.core.MessagesPanel.SelectedMessage = -1 + mi.core.MessagesPanel.Highlight() mi.SetTitle("") } else { - go mi.app.State.SendMessage(mi.app.ChannelsTree.SelectedChannel.ID, t) + go mi.core.State.SendMessage(mi.core.ChannelsTree.SelectedChannel.ID, t) } mi.SetText("") return nil } -func (mi *MessageInput) pasteFromClipboard() *tcell.EventKey { +func (mi *MessageInput) pasteClipboardContentLua(s *lua.LState) int { text, _ := clipboard.ReadAll() text = mi.GetText() + text mi.SetText(text) - return nil + return returnNilLua(s) } -func (mi *MessageInput) openExternalEditor() *tcell.EventKey { +func (mi *MessageInput) openExternalEditorLua(s *lua.LState) int { e := os.Getenv("EDITOR") if e == "" { - return nil + return returnNilLua(s) } f, err := os.CreateTemp(os.TempDir(), "discordo-*.md") if err != nil { - return nil + return returnNilLua(s) } defer os.Remove(f.Name()) @@ -129,7 +168,7 @@ func (mi *MessageInput) openExternalEditor() *tcell.EventKey { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout - mi.app.Suspend(func() { + mi.core.Application.Suspend(func() { err = cmd.Run() if err != nil { return @@ -138,9 +177,9 @@ func (mi *MessageInput) openExternalEditor() *tcell.EventKey { b, err := io.ReadAll(f) if err != nil { - return nil + return returnNilLua(s) } mi.SetText(string(b)) - return nil + return returnNilLua(s) } diff --git a/ui/messages_panel.go b/ui/messages_panel.go index d2adec3..3e51a72 100644 --- a/ui/messages_panel.go +++ b/ui/messages_panel.go @@ -4,21 +4,24 @@ import ( "github.com/diamondburned/arikawa/v3/discord" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + lua "github.com/yuin/gopher-lua" + luar "layeh.com/gopher-luar" ) type MessagesPanel struct { *tview.TextView - app *App - // The index of the currently selected message. + // The index of the currently selected message. A negative index indicates that there is no currently selected message. SelectedMessage int + + core *Core } -func NewMessagesPanel(app *App) *MessagesPanel { +func NewMessagesPanel(c *Core) *MessagesPanel { mp := &MessagesPanel{ - TextView: tview.NewTextView(), - app: app, - // Negative index indicates that there is no currently selected message. + TextView: tview.NewTextView(), SelectedMessage: -1, + + core: c, } mp.SetDynamicColors(true) @@ -26,7 +29,7 @@ func NewMessagesPanel(app *App) *MessagesPanel { mp.SetWordWrap(true) mp.SetInputCapture(mp.onInputCapture) mp.SetChangedFunc(func() { - mp.app.Draw() + mp.core.Application.Draw() }) mp.SetTitle("Messages") @@ -38,30 +41,60 @@ func NewMessagesPanel(app *App) *MessagesPanel { } func (mp *MessagesPanel) onInputCapture(e *tcell.EventKey) *tcell.EventKey { - if mp.app.ChannelsTree.SelectedChannel == nil { + if mp.core.ChannelsTree.SelectedChannel == nil { return nil } // Messages should return messages ordered from latest to earliest. - ms, err := mp.app.State.Cabinet.Messages(mp.app.ChannelsTree.SelectedChannel.ID) + ms, err := mp.core.State.Cabinet.Messages(mp.core.ChannelsTree.SelectedChannel.ID) if err != nil || len(ms) == 0 { return nil } + keysTable, ok := mp.core.Config.State.GetGlobal("keys").(*lua.LTable) + if !ok { + return e + } + + messagesPanel, ok := keysTable.RawGetString("messagesPanel").(*lua.LTable) + if !ok { + return e + } + + var fn lua.LValue + messagesPanel.ForEach(func(k, v lua.LValue) { + keyTable := v.(*lua.LTable) + if e.Name() == lua.LVAsString(keyTable.RawGetString("name")) { + fn = keyTable.RawGetString("action") + } + }) + + if fn != nil { + mp.core.Config.State.CallByParam(lua.P{ + Fn: fn, + NRet: 1, + Protect: true, + }, luar.New(mp.core.Config.State, mp.core), luar.New(mp.core.Config.State, e)) + // Returned value + ret, ok := mp.core.Config.State.Get(-1).(*lua.LUserData) + if !ok { + return e + } + + // Remove returned value + mp.core.Config.State.Pop(1) + + ev, ok := ret.Value.(*tcell.EventKey) + if ok { + return ev + } + } + + // Defaults switch e.Name() { - case mp.app.Config.Keys.SelectPreviousMessage: - return mp.selectPreviousMessage(ms) - case mp.app.Config.Keys.SelectNextMessage: - return mp.selectNextMessage(ms) - case mp.app.Config.Keys.SelectFirstMessage: - return mp.selectFirstMessage(ms) - case mp.app.Config.Keys.SelectLastMessage: - return mp.selectLastMessage(ms) - case mp.app.Config.Keys.OpenMessageActionsList: - return mp.openMessageActionsList(ms) case "Esc": mp.SelectedMessage = -1 - mp.app.SetFocus(mp.app.MainFlex) + mp.core.Application.SetFocus(mp.core.MainFlex) mp. Clear(). Highlight(). @@ -72,7 +105,13 @@ func (mp *MessagesPanel) onInputCapture(e *tcell.EventKey) *tcell.EventKey { return e } -func (mp *MessagesPanel) selectPreviousMessage(ms []discord.Message) *tcell.EventKey { +func (mp *MessagesPanel) selectPreviousMessageLua(s *lua.LState) int { + // Messages should return messages ordered from latest to earliest. + ms, err := mp.core.State.Cabinet.Messages(mp.core.ChannelsTree.SelectedChannel.ID) + if err != nil || len(ms) == 0 { + return returnNilLua(s) + } + // If there are no highlighted regions, select the latest (last) message in the messages panel. if len(mp.GetHighlights()) == 0 { mp.SelectedMessage = 0 @@ -85,13 +124,18 @@ func (mp *MessagesPanel) selectPreviousMessage(ms []discord.Message) *tcell.Even } } - mp. - Highlight(ms[mp.SelectedMessage].ID.String()). - ScrollToHighlight() - return nil + mp.Highlight(ms[mp.SelectedMessage].ID.String()) + mp.ScrollToHighlight() + return returnNilLua(s) } -func (mp *MessagesPanel) selectNextMessage(ms []discord.Message) *tcell.EventKey { +func (mp *MessagesPanel) selectNextMessageLua(s *lua.LState) int { + // Messages should return messages ordered from latest to earliest. + ms, err := mp.core.State.Cabinet.Messages(mp.core.ChannelsTree.SelectedChannel.ID) + if err != nil || len(ms) == 0 { + return returnNilLua(s) + } + // If there are no highlighted regions, select the latest (last) message in the messages panel. if len(mp.GetHighlights()) == 0 { mp.SelectedMessage = 0 @@ -107,42 +151,60 @@ func (mp *MessagesPanel) selectNextMessage(ms []discord.Message) *tcell.EventKey mp. Highlight(ms[mp.SelectedMessage].ID.String()). ScrollToHighlight() - return nil + return returnNilLua(s) } -func (mp *MessagesPanel) selectFirstMessage(ms []discord.Message) *tcell.EventKey { +func (mp *MessagesPanel) selectFirstMessageLua(s *lua.LState) int { + // Messages should return messages ordered from latest to earliest. + ms, err := mp.core.State.Cabinet.Messages(mp.core.ChannelsTree.SelectedChannel.ID) + if err != nil || len(ms) == 0 { + return returnNilLua(s) + } + mp.SelectedMessage = len(ms) - 1 mp. Highlight(ms[mp.SelectedMessage].ID.String()). ScrollToHighlight() - return nil + return returnNilLua(s) } -func (mp *MessagesPanel) selectLastMessage(ms []discord.Message) *tcell.EventKey { +func (mp *MessagesPanel) selectLastMessageLua(s *lua.LState) int { + // Messages should return messages ordered from latest to earliest. + ms, err := mp.core.State.Cabinet.Messages(mp.core.ChannelsTree.SelectedChannel.ID) + if err != nil || len(ms) == 0 { + return returnNilLua(s) + } + mp.SelectedMessage = 0 mp. Highlight(ms[mp.SelectedMessage].ID.String()). ScrollToHighlight() - return nil + return returnNilLua(s) } -func (mp *MessagesPanel) openMessageActionsList(ms []discord.Message) *tcell.EventKey { +func (mp *MessagesPanel) openMessageActionsListLua(s *lua.LState) int { + // Messages should return messages ordered from latest to earliest. + ms, err := mp.core.State.Cabinet.Messages(mp.core.ChannelsTree.SelectedChannel.ID) + if err != nil || len(ms) == 0 { + return returnNilLua(s) + } + hs := mp.GetHighlights() if len(hs) == 0 { - return nil + return returnNilLua(s) } mID, err := discord.ParseSnowflake(hs[0]) if err != nil { - return nil + return returnNilLua(s) } _, m := findMessageByID(ms, discord.MessageID(mID)) if m == nil { - return nil + return returnNilLua(s) } - actionsList := NewMessageActionsList(mp.app, m) - mp.app.SetRoot(actionsList, true) - return nil + actionsList := NewMessageActionsList(mp.core, m) + mp.core.Application.SetRoot(actionsList, true) + return returnNilLua(s) } diff --git a/ui/util.go b/ui/util.go index e0a73ca..ad6063e 100644 --- a/ui/util.go +++ b/ui/util.go @@ -6,6 +6,7 @@ import ( "github.com/diamondburned/arikawa/v3/discord" "github.com/diamondburned/arikawa/v3/state" + lua "github.com/yuin/gopher-lua" ) var ( @@ -66,3 +67,8 @@ func hasPermission(s *state.State, cID discord.ChannelID, p discord.Permissions) return perm&p == p } + +func returnNilLua(s *lua.LState) int { + s.Push(lua.LNil) // Push the result + return 1 // Number of results +}