Sfoglia il codice sorgente

Implement login view (email view & token view) (#223)

ayntgl 3 anni fa
parent
commit
4b61bbf61d
12 ha cambiato i file con 343 aggiunte e 288 eliminazioni
  1. 5 3
      README.md
  2. 11 17
      config/config.go
  3. 26 57
      main.go
  4. 1 1
      ui/channels_tree.go
  5. 143 36
      ui/core.go
  6. 1 1
      ui/guilds_tree.go
  7. 128 0
      ui/login_view.go
  8. 22 22
      ui/message_actions_list.go
  9. 1 1
      ui/message_input.go
  10. 3 3
      ui/messages_panel.go
  11. 0 146
      ui/state.go
  12. 2 1
      ui/util.go

+ 5 - 3
README.md

@@ -65,15 +65,17 @@ sudo mv ./discordo /usr/local/bin
 
 1. Run the `discordo` executable with no arguments.
 
-2. Enter your client authentication token (first-time login) and click on the "Login" button to continue.
+2. A login view will be displayed on first start-up. You can choose to login using either your email and password or token; the views can be switched using `Ctrl+Space` keybinding.
 
-- If you are logging in with a bot account, prefix the token with `Bot ` (eg: `discordo --token "Bot OTI2MDU5NTQxNDE2Nzc5ODA2.Yc2KKA.2iZ-5JxgxG-9Ub8GHzBSn-NJjNg"`). Furthermore, it is strongly recommended to change the user agent for HTTP requests from the configuration file if you are using a bot account to log in since the default user agent is that of a browser.
+3. Enter your credentials and click on the "Login" button to continue.
+
+- If you are logging in with a bot account, prefix the token with `Bot ` (eg: `Bot OTI2MDU5NTQxNDE2Nzc5ODA2.Yc2KKA.2iZ-5JxgxG-9Ub8GHzBSn-NJjNg`).
 
 - Most of the Discord third-party clients store the token in a configuration file unencrypted. Discordo securely stores the token in the default OS-specific keyring. 
 
 ### Configuration
 
-A default configuration file is created on first start-up at `$HOME/.config/discordo/config.yml` on Unix, `$HOME/Library/Application Support/discordo/config.yml` on Darwin, and `%AppData%/discordo/config.yml` on Windows. You can configure the default configuration directory path using the `config` command-line flag (eg: `discordo --config $HOME/my-custom-dir).
+A default configuration file is created on first start-up at `$HOME/.config/discordo/config.yml` on Unix, `$HOME/Library/Application Support/discordo/config.yml` on Darwin, and `%AppData%/discordo/config.yml` on Windows.
 
 ## Disclaimer
 

+ 11 - 17
config/config.go

@@ -80,22 +80,9 @@ func New() *Config {
 	}
 }
 
-func (c *Config) Load() error {
-	configPath, err := os.UserConfigDir()
-	if err != nil {
-		return err
-	}
-
-	configPath = filepath.Join(configPath, Name)
-	// Create directories that do not exist and are mentioned in the path recursively.
-	err = os.MkdirAll(configPath, os.ModePerm)
-	if err != nil {
-		return err
-	}
-
-	configPath = filepath.Join(configPath, "config.yml")
+func (cfg *Config) Load() error {
 	// Open the existing configuration file with read-only flag.
-	f, err := os.OpenFile(configPath, os.O_CREATE|os.O_RDWR, os.ModePerm)
+	f, err := os.OpenFile(cfg.configPath(), os.O_CREATE|os.O_RDWR, os.ModePerm)
 	if err != nil {
 		return err
 	}
@@ -108,8 +95,15 @@ func (c *Config) Load() error {
 
 	// If the configuration file is empty (the size of the file is zero; a new configuration file was created), write the default configuration to the file.
 	if fi.Size() == 0 {
-		return yaml.NewEncoder(f).Encode(c)
+		return yaml.NewEncoder(f).Encode(cfg)
 	}
 
-	return yaml.NewDecoder(f).Decode(&c)
+	return yaml.NewDecoder(f).Decode(&cfg)
+}
+
+func (cfg *Config) configPath() string {
+	path, _ := os.UserConfigDir()
+	// Create the configuration directory if it does not exist already.
+	_ = os.MkdirAll(filepath.Join(path, Name), os.ModePerm)
+	return filepath.Join(path, "config.yml")
 }

+ 26 - 57
main.go

@@ -1,32 +1,41 @@
 package main
 
 import (
-	"flag"
+	"fmt"
 	"log"
+	"runtime"
 
 	"github.com/ayntgl/discordo/config"
 	"github.com/ayntgl/discordo/ui"
+	"github.com/diamondburned/arikawa/v3/api"
+	"github.com/diamondburned/arikawa/v3/gateway"
 	"github.com/rivo/tview"
 	"github.com/zalando/go-keyring"
 )
 
-var token string
-
 func init() {
-	flag.StringVar(&token, "token", "", "The client authentication token.")
-	// If the token is provided via a command-line flag, store it in the default keyring.
-	if token != "" {
-		go keyring.Set(config.Name, "token", token)
-	}
+	tview.Borders.TopLeftFocus = tview.Borders.TopLeft
+	tview.Borders.TopRightFocus = tview.Borders.TopRight
+	tview.Borders.BottomLeftFocus = tview.Borders.BottomLeft
+	tview.Borders.BottomRightFocus = tview.Borders.BottomRight
+	tview.Borders.HorizontalFocus = tview.Borders.Horizontal
+	tview.Borders.VerticalFocus = tview.Borders.Vertical
+	tview.Borders.TopLeft = 0
+	tview.Borders.TopRight = 0
+	tview.Borders.BottomLeft = 0
+	tview.Borders.BottomRight = 0
+	tview.Borders.Horizontal = 0
+	tview.Borders.Vertical = 0
 
-	if token == "" {
-		token, _ = keyring.Get(config.Name, "token")
+	api.UserAgent = fmt.Sprintf("%s/%s %s/%s", config.Name, "0.1", "arikawa", "v3")
+	gateway.DefaultIdentity = gateway.IdentifyProperties{
+		OS:      runtime.GOOS,
+		Browser: config.Name,
+		Device:  "",
 	}
 }
 
 func main() {
-	flag.Parse()
-
 	cfg := config.New()
 	err := cfg.Load()
 	if err != nil {
@@ -34,60 +43,20 @@ func main() {
 	}
 
 	c := ui.NewCore(cfg)
+	token, _ := keyring.Get(config.Name, "token")
 	if token != "" {
 		err = c.Run(token)
 		if err != nil {
 			log.Fatal(err)
 		}
 
-		c.DrawMainFlex()
-		c.Application.SetRoot(c.MainFlex, true)
-		c.Application.SetFocus(c.GuildsTree)
+		c.Draw()
 	} else {
-		loginForm := tview.NewForm()
-		loginForm.AddPasswordField("Token", "", 0, 0, nil)
-		loginForm.SetButtonsAlign(tview.AlignCenter)
-
-		loginForm.SetTitle("Login")
-		loginForm.SetTitleAlign(tview.AlignLeft)
-		loginForm.SetBorder(true)
-		loginForm.SetBorderPadding(0, 0, 1, 1)
-
-		loginForm.AddButton("Login", func() {
-			tkn := loginForm.GetFormItem(0).(*tview.InputField).GetText()
-			if tkn == "" {
-				return
-			}
-
-			err := c.Run(tkn)
-			if err != nil {
-				log.Fatal(err)
-			}
-
-			go keyring.Set(config.Name, "token", tkn)
-
-			c.DrawMainFlex()
-			c.Application.SetRoot(c.MainFlex, true)
-			c.Application.SetFocus(c.GuildsTree)
-		})
-
-		c.Application.SetRoot(loginForm, true)
+		loginView := ui.NewLoginView(c)
+		c.App.SetRoot(loginView, true)
 	}
 
-	tview.Borders.TopLeftFocus = tview.Borders.TopLeft
-	tview.Borders.TopRightFocus = tview.Borders.TopRight
-	tview.Borders.BottomLeftFocus = tview.Borders.BottomLeft
-	tview.Borders.BottomRightFocus = tview.Borders.BottomRight
-	tview.Borders.HorizontalFocus = tview.Borders.Horizontal
-	tview.Borders.VerticalFocus = tview.Borders.Vertical
-	tview.Borders.TopLeft = 0
-	tview.Borders.TopRight = 0
-	tview.Borders.BottomLeft = 0
-	tview.Borders.BottomRight = 0
-	tview.Borders.Horizontal = 0
-	tview.Borders.Vertical = 0
-
-	err = c.Application.Run()
+	err = c.App.Run()
 	if err != nil {
 		log.Fatal(err)
 	}

+ 1 - 1
ui/channels_tree.go

@@ -54,7 +54,7 @@ func (ct *ChannelsTree) onSelected(node *tview.TreeNode) {
 	}
 
 	ct.SelectedChannel = c
-	ct.core.Application.SetFocus(ct.core.MessageInput)
+	ct.core.App.SetFocus(ct.core.MessageInput)
 
 	title := channelToString(*c)
 	if c.Topic != "" {

+ 143 - 36
ui/core.go

@@ -1,15 +1,21 @@
 package ui
 
 import (
+	"context"
+	"strings"
+
 	"github.com/ayntgl/discordo/config"
+	"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 focused int
+type FocusedID int
 
 const (
-	guildsTree focused = iota
+	guildsTree FocusedID = iota
 	channelsTree
 	messagesPanel
 	messageInput
@@ -20,24 +26,21 @@ const (
 // - 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
+	App           *tview.Application
+	View          *tview.Flex
 	GuildsTree    *GuildsTree
 	ChannelsTree  *ChannelsTree
 	MessagesPanel *MessagesPanel
 	MessageInput  *MessageInput
 
 	Config *config.Config
-	State  *State
+	State  *state.State
 
-	focused focused
+	focusedID FocusedID
 }
 
 func NewCore(cfg *config.Config) *Core {
 	c := &Core{
-		Application: tview.NewApplication(),
-		MainFlex:    tview.NewFlex(),
-
 		Config: cfg,
 	}
 
@@ -45,37 +48,48 @@ func NewCore(cfg *config.Config) *Core {
 	tview.Styles.BorderColor = tcell.GetColor(cfg.Theme.Border)
 	tview.Styles.TitleColor = tcell.GetColor(cfg.Theme.Title)
 
-	c.Application.EnableMouse(c.Config.Mouse)
-	c.Application.SetInputCapture(c.onInputCapture)
-	c.Application.SetBeforeDrawFunc(c.beforeDraw)
+	c.App = tview.NewApplication()
+	c.App.EnableMouse(c.Config.Mouse)
+	c.App.SetBeforeDrawFunc(c.onAppBeforeDraw)
+
+	c.View = tview.NewFlex()
+	c.View.SetInputCapture(c.onViewInputCapture)
 
 	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 {
-	c.State = NewState(token, c)
-	return c.State.Run()
+	c.State = state.New(token)
+	c.State.AddHandler(c.onReady)
+	c.State.AddHandler(c.onGuildCreate)
+	c.State.AddHandler(c.onGuildDelete)
+	c.State.AddHandler(c.onMessageCreate)
+	return c.State.Open(context.Background())
 }
 
-func (c *Core) DrawMainFlex() {
-	leftFlex := tview.NewFlex().
+func (c *Core) Draw() {
+	left := tview.NewFlex().
 		SetDirection(tview.FlexRow).
 		AddItem(c.GuildsTree, 10, 1, false).
 		AddItem(c.ChannelsTree, 0, 1, false)
-	rightFlex := tview.NewFlex().
+	right := 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)
+
+	c.View.AddItem(left, 0, 1, false)
+	c.View.AddItem(right, 0, 4, false)
+
+	c.App.SetRoot(c.View, true)
+	c.App.SetFocus(c.GuildsTree)
 }
 
-func (c *Core) beforeDraw(screen tcell.Screen) bool {
+func (c *Core) onAppBeforeDraw(screen tcell.Screen) bool {
 	if c.Config.Theme.Background == "default" {
 		screen.Clear()
 	}
@@ -83,30 +97,25 @@ func (c *Core) beforeDraw(screen tcell.Screen) bool {
 	return false
 }
 
-func (c *Core) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
-	// 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 event
-	}
-
+func (c *Core) onViewInputCapture(event *tcell.EventKey) *tcell.EventKey {
 	switch event.Key() {
 	case tcell.KeyEsc:
-		c.focused = 0
+		c.focusedID = 0
 	case tcell.KeyBacktab:
 		// If the currently focused widget is the guilds tree widget (first), then focus the message input widget (last)
-		if c.focused == 0 {
-			c.focused = messageInput
+		if c.focusedID == 0 {
+			c.focusedID = messageInput
 		} else {
-			c.focused--
+			c.focusedID--
 		}
 
 		c.setFocus()
 	case tcell.KeyTab:
 		// If the currently focused widget is the message input widget (last), then focus the guilds tree widget (first)
-		if c.focused == messageInput {
-			c.focused = guildsTree
+		if c.focusedID == messageInput {
+			c.focusedID = guildsTree
 		} else {
-			c.focused++
+			c.focusedID++
 		}
 
 		c.setFocus()
@@ -117,7 +126,7 @@ func (c *Core) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
 
 func (c *Core) setFocus() {
 	var p tview.Primitive
-	switch c.focused {
+	switch c.focusedID {
 	case guildsTree:
 		p = c.GuildsTree
 	case channelsTree:
@@ -128,5 +137,103 @@ func (c *Core) setFocus() {
 		p = c.MessageInput
 	}
 
-	c.Application.SetFocus(p)
+	c.App.SetFocus(p)
+}
+
+func (c *Core) onReady(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.App.SetFocus(c.GuildsTree)
+}
+
+func (c *Core) onGuildCreate(g *gateway.GuildCreateEvent) {
+	guildNode := tview.NewTreeNode(g.Name)
+	guildNode.SetReference(g.ID)
+
+	rootNode := c.GuildsTree.GetRoot()
+	rootNode.AddChild(guildNode)
+
+	c.GuildsTree.SetCurrentNode(rootNode)
+	c.App.SetFocus(c.GuildsTree)
+	c.App.Draw()
+}
+
+func (c *Core) onGuildDelete(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.App.Draw()
+}
+
+func (c *Core) onMessageCreate(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()
+		}
+	}
 }

+ 1 - 1
ui/guilds_tree.go

@@ -57,5 +57,5 @@ func (gt *GuildsTree) onSelected(node *tview.TreeNode) {
 	}
 
 	gt.core.ChannelsTree.SetCurrentNode(rootNode)
-	gt.core.Application.SetFocus(gt.core.ChannelsTree)
+	gt.core.App.SetFocus(gt.core.ChannelsTree)
 }

+ 128 - 0
ui/login_view.go

@@ -0,0 +1,128 @@
+package ui
+
+import (
+	"context"
+	"log"
+
+	"github.com/ayntgl/discordo/config"
+	"github.com/diamondburned/arikawa/v3/api"
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+	"github.com/zalando/go-keyring"
+)
+
+const (
+	emailViewPageName = "email"
+	tokenViewPageName = "token"
+)
+
+func NewLoginView(c *Core) *tview.Pages {
+	v := tview.NewPages()
+
+	v.AddPage(emailViewPageName, newEmailView(c), true, true)
+	v.AddPage(tokenViewPageName, newTokenView(c), true, true)
+	// The email view is displayed on the screen first since it is the recommended method to login.
+	v.SwitchToPage(emailViewPageName)
+
+	v.SetTitle("Login")
+	v.SetTitleAlign(tview.AlignLeft)
+	v.SetBorder(true)
+	v.SetBorderPadding(0, 0, 1, 1)
+	v.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+		if event.Key() == tcell.KeyCtrlSpace {
+			name, _ := v.GetFrontPage()
+
+			switch name {
+			case emailViewPageName:
+				name = tokenViewPageName
+			case tokenViewPageName:
+				name = emailViewPageName
+			}
+
+			v.SwitchToPage(name)
+		}
+
+		return event
+	})
+
+	return v
+}
+
+type EmailView struct {
+	*tview.Form
+	core *Core
+}
+
+func newEmailView(c *Core) *EmailView {
+	v := &EmailView{
+		Form: tview.NewForm(),
+		core: c,
+	}
+
+	v.AddInputField("Email", "", 0, nil, nil)
+	v.AddPasswordField("Password", "", 0, 0, nil)
+	v.AddButton("Login", v.onLoginButtonSelected)
+
+	return v
+}
+
+func (v *EmailView) onLoginButtonSelected() {
+	email := v.GetFormItem(0).(*tview.InputField).GetText()
+	password := v.GetFormItem(1).(*tview.InputField).GetText()
+	if email == "" || password == "" {
+		return
+	}
+
+	// Make a scratch HTTP client without a token
+	client := api.NewClient("").WithContext(context.Background())
+
+	// Try to login without TOTP
+	l, err := client.Login(email, password)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	if l.Token != "" && !l.MFA {
+		err = v.core.Run(l.Token)
+		if err != nil {
+			log.Fatal(err)
+		}
+
+		v.core.Draw()
+		go keyring.Set(config.Name, "token", l.Token)
+	}
+
+	// TODO: MFA login
+}
+
+type TokenView struct {
+	*tview.Form
+	core *Core
+}
+
+func newTokenView(c *Core) *TokenView {
+	v := &TokenView{
+		Form: tview.NewForm(),
+		core: c,
+	}
+
+	v.AddPasswordField("Token", "", 0, 0, nil)
+	v.AddButton("Login", v.onLoginButtonSelected)
+
+	return v
+}
+
+func (v *TokenView) onLoginButtonSelected() {
+	token := v.GetFormItem(0).(*tview.InputField).GetText()
+	if token == "" {
+		return
+	}
+
+	err := v.core.Run(token)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	v.core.Draw()
+	go keyring.Set(config.Name, "token", token)
+}

+ 22 - 22
ui/message_actions_list.go

@@ -30,8 +30,8 @@ func NewMessageActionsList(c *Core, m *discord.Message) *MessageActionsList {
 
 	mal.ShowSecondaryText(false)
 	mal.SetDoneFunc(func() {
-		c.Application.SetRoot(c.MainFlex, true)
-		c.Application.SetFocus(c.MessagesPanel)
+		c.App.SetRoot(c.View, true)
+		c.App.SetFocus(c.MessagesPanel)
 	})
 
 	// If the client user has the `SEND_MESSAGES` permission, add "Reply" and "Mention Reply" actions.
@@ -53,8 +53,8 @@ func NewMessageActionsList(c *Core, m *discord.Message) *MessageActionsList {
 				go open.Run(l)
 			}
 
-			c.Application.SetRoot(c.MainFlex, true)
-			c.Application.SetFocus(c.MessagesPanel)
+			c.App.SetRoot(c.View, true)
+			c.App.SetFocus(c.MessagesPanel)
 		})
 	}
 
@@ -84,15 +84,15 @@ func NewMessageActionsList(c *Core, m *discord.Message) *MessageActionsList {
 func (mal *MessageActionsList) replyAction() {
 	mal.core.MessageInput.SetTitle("Replying to " + mal.message.Author.Tag())
 
-	mal.core.Application.SetRoot(mal.core.MainFlex, true)
-	mal.core.Application.SetFocus(mal.core.MessageInput)
+	mal.core.App.SetRoot(mal.core.View, true)
+	mal.core.App.SetFocus(mal.core.MessageInput)
 }
 
 func (mal *MessageActionsList) mentionReplyAction() {
 	mal.core.MessageInput.SetTitle("[@] Replying to " + mal.message.Author.Tag())
 
-	mal.core.Application.SetRoot(mal.core.MainFlex, true)
-	mal.core.Application.SetFocus(mal.core.MessageInput)
+	mal.core.App.SetRoot(mal.core.View, true)
+	mal.core.App.SetFocus(mal.core.MessageInput)
 }
 
 func (mal *MessageActionsList) selectReplyAction() {
@@ -106,8 +106,8 @@ func (mal *MessageActionsList) selectReplyAction() {
 		Highlight(mal.message.ReferencedMessage.ID.String()).
 		ScrollToHighlight()
 
-	mal.core.Application.SetRoot(mal.core.MainFlex, true)
-	mal.core.Application.SetFocus(mal.core.MessagesPanel)
+	mal.core.App.SetRoot(mal.core.View, true)
+	mal.core.App.SetFocus(mal.core.MessagesPanel)
 }
 
 func (mal *MessageActionsList) openAttachmentAction() {
@@ -133,8 +133,8 @@ func (mal *MessageActionsList) openAttachmentAction() {
 		go open.Run(f.Name())
 	}
 
-	mal.core.Application.SetRoot(mal.core.MainFlex, true)
-	mal.core.Application.SetFocus(mal.core.MessagesPanel)
+	mal.core.App.SetRoot(mal.core.View, true)
+	mal.core.App.SetFocus(mal.core.MessagesPanel)
 }
 
 func (mal *MessageActionsList) downloadAttachmentAction() {
@@ -164,8 +164,8 @@ func (mal *MessageActionsList) downloadAttachmentAction() {
 		f.Write(d)
 	}
 
-	mal.core.Application.SetRoot(mal.core.MainFlex, true)
-	mal.core.Application.SetFocus(mal.core.MessagesPanel)
+	mal.core.App.SetRoot(mal.core.View, true)
+	mal.core.App.SetFocus(mal.core.MessagesPanel)
 }
 
 func (mal *MessageActionsList) deleteAction() {
@@ -194,8 +194,8 @@ func (mal *MessageActionsList) deleteAction() {
 		}
 	}
 
-	mal.core.Application.SetRoot(mal.core.MainFlex, true)
-	mal.core.Application.SetFocus(mal.core.MessagesPanel)
+	mal.core.App.SetRoot(mal.core.View, true)
+	mal.core.App.SetFocus(mal.core.MessagesPanel)
 }
 
 func (mal *MessageActionsList) copyContentAction() {
@@ -204,8 +204,8 @@ func (mal *MessageActionsList) copyContentAction() {
 		return
 	}
 
-	mal.core.Application.SetRoot(mal.core.MainFlex, true)
-	mal.core.Application.SetFocus(mal.core.MessagesPanel)
+	mal.core.App.SetRoot(mal.core.View, true)
+	mal.core.App.SetFocus(mal.core.MessagesPanel)
 }
 
 func (mal *MessageActionsList) copyIDAction() {
@@ -214,8 +214,8 @@ func (mal *MessageActionsList) copyIDAction() {
 		return
 	}
 
-	mal.core.Application.SetRoot(mal.core.MainFlex, true)
-	mal.core.Application.SetFocus(mal.core.MessagesPanel)
+	mal.core.App.SetRoot(mal.core.View, true)
+	mal.core.App.SetFocus(mal.core.MessagesPanel)
 }
 
 func (mal *MessageActionsList) copyLinkAction() {
@@ -224,6 +224,6 @@ func (mal *MessageActionsList) copyLinkAction() {
 		return
 	}
 
-	mal.core.Application.SetRoot(mal.core.MainFlex, true)
-	mal.core.Application.SetFocus(mal.core.MessagesPanel)
+	mal.core.App.SetRoot(mal.core.View, true)
+	mal.core.App.SetFocus(mal.core.MessagesPanel)
 }

+ 1 - 1
ui/message_input.go

@@ -127,7 +127,7 @@ func (mi *MessageInput) openExternalEditor() *tcell.EventKey {
 	cmd.Stdin = os.Stdin
 	cmd.Stdout = os.Stdout
 
-	mi.core.Application.Suspend(func() {
+	mi.core.App.Suspend(func() {
 		err = cmd.Run()
 		if err != nil {
 			return

+ 3 - 3
ui/messages_panel.go

@@ -27,7 +27,7 @@ func NewMessagesPanel(c *Core) *MessagesPanel {
 	mp.SetWordWrap(true)
 	mp.SetInputCapture(mp.onInputCapture)
 	mp.SetChangedFunc(func() {
-		mp.core.Application.Draw()
+		mp.core.App.Draw()
 	})
 
 	mp.SetTitle("Messages")
@@ -64,7 +64,7 @@ func (mp *MessagesPanel) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
 		return mp.selectLastMessage(ms)
 	case "Esc":
 		mp.SelectedMessage = -1
-		mp.core.Application.SetFocus(mp.core.MainFlex)
+		mp.core.App.SetFocus(mp.core.View)
 		mp.
 			Clear().
 			Highlight().
@@ -145,6 +145,6 @@ func (mp *MessagesPanel) openMessageActionsList(ms []discord.Message) *tcell.Eve
 	}
 
 	actionsList := NewMessageActionsList(mp.core, m)
-	mp.core.Application.SetRoot(actionsList, true)
+	mp.core.App.SetRoot(actionsList, true)
 	return nil
 }

+ 0 - 146
ui/state.go

@@ -1,146 +0,0 @@
-package ui
-
-import (
-	"context"
-	"fmt"
-	"runtime"
-	"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/rivo/tview"
-)
-
-func init() {
-	api.UserAgent = fmt.Sprintf("%s/%s %s/%s", config.Name, "0.1", "arikawa", "v3")
-	gateway.DefaultIdentity = gateway.IdentifyProperties{
-		OS:      runtime.GOOS,
-		Browser: config.Name,
-	}
-}
-
-type State struct {
-	*state.State
-	core *Core
-}
-
-func NewState(token string, c *Core) *State {
-	return &State{
-		State: state.New(token),
-		core:  c,
-	}
-}
-
-func (s *State) Run() error {
-	// Add the essential intents to the identify data for bot accounts.
-	if strings.HasPrefix(s.Token, "Bot") {
-		s.AddIntents(gateway.IntentGuilds | gateway.IntentGuildMessages)
-	}
-
-	s.AddHandler(s.ready)
-	s.AddHandler(s.guildCreate)
-	s.AddHandler(s.guildDelete)
-	s.AddHandler(s.messageCreate)
-	return s.Open(context.Background())
-}
-
-func (s *State) ready(r *gateway.ReadyEvent) {
-	rootNode := s.core.GuildsTree.GetRoot()
-	for _, gf := range r.UserSettings.GuildFolders {
-		if gf.ID == 0 {
-			for _, gID := range gf.GuildIDs {
-				g, err := s.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 := s.State.Cabinet.Guild(gID)
-				if err != nil {
-					return
-				}
-
-				guildNode := tview.NewTreeNode(g.Name)
-				guildNode.SetReference(g.ID)
-				folderNode.AddChild(guildNode)
-			}
-		}
-
-	}
-
-	s.core.GuildsTree.SetCurrentNode(rootNode)
-	s.core.Application.SetFocus(s.core.GuildsTree)
-}
-
-func (s *State) guildCreate(g *gateway.GuildCreateEvent) {
-	guildNode := tview.NewTreeNode(g.Name)
-	guildNode.SetReference(g.ID)
-
-	rootNode := s.core.GuildsTree.GetRoot()
-	rootNode.AddChild(guildNode)
-
-	s.core.GuildsTree.SetCurrentNode(rootNode)
-	s.core.Application.SetFocus(s.core.GuildsTree)
-	s.core.Application.Draw()
-}
-
-func (s *State) guildDelete(g *gateway.GuildDeleteEvent) {
-	rootNode := s.core.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)
-	}
-
-	s.core.Application.Draw()
-}
-
-func (s *State) messageCreate(m *gateway.MessageCreateEvent) {
-	if s.core.ChannelsTree.SelectedChannel != nil && s.core.ChannelsTree.SelectedChannel.ID == m.ChannelID {
-		_, err := s.core.MessagesPanel.Write(buildMessage(s.core, m.Message))
-		if err != nil {
-			return
-		}
-
-		if len(s.core.MessagesPanel.GetHighlights()) == 0 {
-			s.core.MessagesPanel.ScrollToEnd()
-		}
-	}
-}

+ 2 - 1
ui/util.go

@@ -5,6 +5,7 @@ import (
 	"strings"
 
 	"github.com/diamondburned/arikawa/v3/discord"
+	"github.com/diamondburned/arikawa/v3/state"
 )
 
 var (
@@ -57,7 +58,7 @@ func findMessageByID(ms []discord.Message, mID discord.MessageID) (int, *discord
 	return -1, nil
 }
 
-func hasPermission(s *State, cID discord.ChannelID, p discord.Permissions) bool {
+func hasPermission(s *state.State, cID discord.ChannelID, p discord.Permissions) bool {
 	perm, err := s.Permissions(cID, s.Ready().User.ID)
 	if err != nil {
 		return false