Explorar o código

Implement scripting interface (#194)

ayntgl %!s(int64=3) %!d(string=hai) anos
pai
achega
41ed0bc604
Modificáronse 15 ficheiros con 745 adicións e 494 borrados
  1. 1 1
      README.md
  2. 43 107
      config/config.go
  3. 127 0
      config/config.lua
  4. 4 3
      go.mod
  5. 13 4
      go.sum
  6. 41 33
      main.go
  7. 0 205
      ui/app.go
  8. 13 7
      ui/builder.go
  9. 17 15
      ui/channels_tree.go
  10. 261 0
      ui/core.go
  11. 12 12
      ui/guilds_tree.go
  12. 42 43
      ui/message_actions_list.go
  13. 65 26
      ui/message_input.go
  14. 100 38
      ui/messages_panel.go
  15. 6 0
      ui/util.go

+ 1 - 1
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
 

+ 43 - 107
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)
-	if err != nil {
-		return err
+	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()
 	}
-	defer f.Close()
 
-	fi, err := f.Stat()
 	if err != nil {
 		return err
 	}
+	defer f.Close()
 
-	// 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
-}
-
-func DefaultPath() string {
-	path, err := os.UserConfigDir()
+	b, err := io.ReadAll(f)
 	if err != nil {
-		log.Fatal(err)
+		return err
 	}
 
-	path += "/discordo/config.toml"
-	return path
+	LuaConfig = b
+	return nil
 }
 
-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
-	}
+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))
 
-	return dlloc
+	s.Push(keyTable) // Push the result
+	return 1         // Number of results
 }

+ 127 - 0
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"}

+ 4 - 3
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

+ 13 - 4
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=

+ 41 - 33
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
+	}
+
+	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 = app.Run()
+	err := c.Application.Run()
 	if err != nil {
 		log.Fatal(err)
 	}

+ 0 - 205
ui/app.go

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

+ 13 - 7
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)[::-]")

+ 17 - 15
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
 	}

+ 261 - 0
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()
+		}
+	}
+}

+ 12 - 12
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)
 }

+ 42 - 43
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)
 }

+ 65 - 26
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)
 }

+ 100 - 38
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)
 }

+ 6 - 0
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
+}