Parcourir la source

Rename inconsistent widget names (#224)

ayntgl il y a 3 ans
Parent
commit
0bbe6bf93a
12 fichiers modifiés avec 796 ajouts et 799 suppressions
  1. 8 8
      config/config.go
  2. 229 0
      ui/actions_view.go
  3. 0 162
      ui/channels_tree.go
  4. 161 0
      ui/channels_view.go
  5. 45 45
      ui/core.go
  6. 0 61
      ui/guilds_tree.go
  7. 61 0
      ui/guilds_view.go
  8. 144 0
      ui/input_view.go
  9. 0 229
      ui/message_actions_list.go
  10. 0 144
      ui/message_input.go
  11. 0 150
      ui/messages_panel.go
  12. 148 0
      ui/messages_view.go

+ 8 - 8
config/config.go

@@ -11,8 +11,8 @@ import (
 
 const Name = "discordo"
 
-type MessagesPanelKeysConfig struct {
-	OpenActionsList string `yaml:"open_actions_list"`
+type MessagesViewKeysConfig struct {
+	OpenActionsView string `yaml:"open_actions_view"`
 
 	SelectPreviousMessage string `yaml:"select_previous_message"`
 	SelectNextMessage     string `yaml:"select_next_message"`
@@ -20,14 +20,14 @@ type MessagesPanelKeysConfig struct {
 	SelectLastMessage     string `yaml:"select_last_message"`
 }
 
-type MessageInputKeysConfig struct {
+type InputViewKeysConfig struct {
 	OpenExternalEditor string `yaml:"open_external_editor"`
 	PasteClipboard     string `yaml:"paste_clipboard"`
 }
 
 type KeysConfig struct {
-	MessagesPanel MessagesPanelKeysConfig `yaml:"messages_panel"`
-	MessageInput  MessageInputKeysConfig  `yaml:"message_input"`
+	MessagesView MessagesViewKeysConfig `yaml:"messages_view"`
+	InputView    InputViewKeysConfig    `yaml:"input_view"`
 }
 
 type ThemeConfig struct {
@@ -39,7 +39,7 @@ type ThemeConfig struct {
 type Config struct {
 	// Whether the mouse is usable or not.
 	Mouse bool `yaml:"mouse"`
-	// 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.
+	// The maximum number of messages to fetch and display. Its value must not be lesser than 1 and greater than 100.
 	MessagesLimit uint `yaml:"messages_limit"`
 	// Whether to display the timestamps of the messages beside the displayed message or not.
 	Timestamps bool `yaml:"timestamps"`
@@ -63,8 +63,8 @@ func New() *Config {
 		TimeFormat: time.Kitchen,
 
 		Keys: KeysConfig{
-			MessagesPanel: MessagesPanelKeysConfig{
-				OpenActionsList: "Rune[a]",
+			MessagesView: MessagesViewKeysConfig{
+				OpenActionsView: "Rune[a]",
 
 				SelectPreviousMessage: "Up",
 				SelectNextMessage:     "Down",

+ 229 - 0
ui/actions_view.go

@@ -0,0 +1,229 @@
+package ui
+
+import (
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"regexp"
+
+	"github.com/atotto/clipboard"
+	"github.com/diamondburned/arikawa/v3/discord"
+	"github.com/rivo/tview"
+	"github.com/skratchdot/open-golang/open"
+)
+
+var linkRegex = regexp.MustCompile("https?://.+")
+
+type ActionsView struct {
+	*tview.List
+	core    *Core
+	message *discord.Message
+}
+
+func newActionsView(c *Core, m *discord.Message) *ActionsView {
+	v := &ActionsView{
+		List:    tview.NewList(),
+		core:    c,
+		message: m,
+	}
+
+	v.ShowSecondaryText(false)
+	v.SetDoneFunc(func() {
+		c.App.SetRoot(c.View, true)
+		c.App.SetFocus(c.MessagesView)
+	})
+
+	// If the client user has the `SEND_MESSAGES` permission, add "Reply" and "Mention Reply" actions.
+	if hasPermission(c.State, c.ChannelsView.selectedChannel.ID, discord.PermissionSendMessages) {
+		v.AddItem("Reply", "", 'r', v.replyAction)
+		v.AddItem("Mention Reply", "", 'R', v.mentionReplyAction)
+	}
+
+	// If the referenced message exists, add a new action to select the reply.
+	if m.ReferencedMessage != nil {
+		v.AddItem("Select Reply", "", 'm', v.selectReplyAction)
+	}
+
+	// If the content of the message contains link(s), add the appropriate actions.
+	links := linkRegex.FindAllString(m.Content, -1)
+	if len(links) != 0 {
+		v.AddItem("Open Link", "", 'l', func() {
+			for _, l := range links {
+				go open.Run(l)
+			}
+
+			c.App.SetRoot(c.View, true)
+			c.App.SetFocus(c.MessagesView)
+		})
+	}
+
+	// If the message contains attachments, add the appropriate actions to the actions view.
+	if len(m.Attachments) != 0 {
+		v.AddItem("Open Attachment", "", 'o', v.openAttachmentAction)
+		v.AddItem("Download Attachment", "", 'd', v.downloadAttachmentAction)
+	}
+
+	// If the client user has the `MANAGE_MESSAGES` permission, add a new action to delete the message.
+	if hasPermission(c.State, c.ChannelsView.selectedChannel.ID, discord.PermissionManageMessages) {
+		v.AddItem("Delete", "", 'd', v.deleteAction)
+	}
+
+	v.AddItem("Copy Content", "", 'c', v.copyContentAction)
+	v.AddItem("Copy ID", "", 'i', v.copyIDAction)
+	v.AddItem("Copy Link", "", 'k', v.copyLinkAction)
+
+	v.SetTitle("Press the Escape key to close")
+	v.SetTitleAlign(tview.AlignLeft)
+	v.SetBorder(true)
+	v.SetBorderPadding(0, 0, 1, 1)
+
+	return v
+}
+
+func (v *ActionsView) replyAction() {
+	v.core.InputView.SetTitle("Replying to " + v.message.Author.Tag())
+
+	v.core.App.SetRoot(v.core.View, true)
+	v.core.App.SetFocus(v.core.InputView)
+}
+
+func (v *ActionsView) mentionReplyAction() {
+	v.core.InputView.SetTitle("[@] Replying to " + v.message.Author.Tag())
+
+	v.core.App.SetRoot(v.core.View, true)
+	v.core.App.SetFocus(v.core.InputView)
+}
+
+func (v *ActionsView) selectReplyAction() {
+	ms, err := v.core.State.Cabinet.Messages(v.message.ChannelID)
+	if err != nil {
+		return
+	}
+
+	v.core.MessagesView.selectedMessage, _ = findMessageByID(ms, v.message.ReferencedMessage.ID)
+	v.core.MessagesView.
+		Highlight(v.message.ReferencedMessage.ID.String()).
+		ScrollToHighlight()
+
+	v.core.App.SetRoot(v.core.View, true)
+	v.core.App.SetFocus(v.core.MessagesView)
+}
+
+func (v *ActionsView) openAttachmentAction() {
+	for _, a := range v.message.Attachments {
+		cacheDirPath, _ := os.UserCacheDir()
+		f, err := os.Create(filepath.Join(cacheDirPath, a.Filename))
+		if err != nil {
+			return
+		}
+		defer f.Close()
+
+		resp, err := http.Get(a.URL)
+		if err != nil {
+			return
+		}
+
+		d, err := io.ReadAll(resp.Body)
+		if err != nil {
+			return
+		}
+
+		f.Write(d)
+		go open.Run(f.Name())
+	}
+
+	v.core.App.SetRoot(v.core.View, true)
+	v.core.App.SetFocus(v.core.MessagesView)
+}
+
+func (v *ActionsView) downloadAttachmentAction() {
+	for _, a := range v.message.Attachments {
+		path, err := os.UserHomeDir()
+		if err != nil {
+			path = os.TempDir()
+		}
+
+		path = filepath.Join(path, "Downloads", a.Filename)
+		f, err := os.Create(path)
+		if err != nil {
+			return
+		}
+		defer f.Close()
+
+		resp, err := http.Get(a.URL)
+		if err != nil {
+			return
+		}
+
+		d, err := io.ReadAll(resp.Body)
+		if err != nil {
+			return
+		}
+
+		f.Write(d)
+	}
+
+	v.core.App.SetRoot(v.core.View, true)
+	v.core.App.SetFocus(v.core.MessagesView)
+}
+
+func (v *ActionsView) deleteAction() {
+	v.core.MessagesView.Clear()
+
+	err := v.core.State.MessageRemove(v.message.ChannelID, v.message.ID)
+	if err != nil {
+		return
+	}
+
+	err = v.core.State.DeleteMessage(v.message.ChannelID, v.message.ID, "Unknown")
+	if err != nil {
+		return
+	}
+
+	// The returned slice will be sorted from latest to oldest.
+	ms, err := v.core.State.Cabinet.Messages(v.message.ChannelID)
+	if err != nil {
+		return
+	}
+
+	for i := len(ms) - 1; i >= 0; i-- {
+		_, err = v.core.MessagesView.Write(buildMessage(v.core, ms[i]))
+		if err != nil {
+			return
+		}
+	}
+
+	v.core.App.SetRoot(v.core.View, true)
+	v.core.App.SetFocus(v.core.MessagesView)
+}
+
+func (v *ActionsView) copyContentAction() {
+	err := clipboard.WriteAll(v.message.Content)
+	if err != nil {
+		return
+	}
+
+	v.core.App.SetRoot(v.core.View, true)
+	v.core.App.SetFocus(v.core.MessagesView)
+}
+
+func (v *ActionsView) copyIDAction() {
+	err := clipboard.WriteAll(v.message.ID.String())
+	if err != nil {
+		return
+	}
+
+	v.core.App.SetRoot(v.core.View, true)
+	v.core.App.SetFocus(v.core.MessagesView)
+}
+
+func (v *ActionsView) copyLinkAction() {
+	err := clipboard.WriteAll(v.message.URL())
+	if err != nil {
+		return
+	}
+
+	v.core.App.SetRoot(v.core.View, true)
+	v.core.App.SetFocus(v.core.MessagesView)
+}

+ 0 - 162
ui/channels_tree.go

@@ -1,162 +0,0 @@
-package ui
-
-import (
-	"sort"
-
-	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/rivo/tview"
-)
-
-type ChannelsTree struct {
-	*tview.TreeView
-	SelectedChannel *discord.Channel
-
-	core *Core
-}
-
-func NewChannelsTree(c *Core) *ChannelsTree {
-	ct := &ChannelsTree{
-		TreeView: tview.NewTreeView(),
-		core:     c,
-	}
-
-	ct.SetRoot(tview.NewTreeNode(""))
-	ct.SetTopLevel(1)
-	ct.SetSelectedFunc(ct.onSelected)
-
-	ct.SetTitle("Channels")
-	ct.SetTitleAlign(tview.AlignLeft)
-	ct.SetBorder(true)
-	ct.SetBorderPadding(0, 0, 1, 1)
-
-	return ct
-}
-
-func (ct *ChannelsTree) onSelected(node *tview.TreeNode) {
-	ct.SelectedChannel = nil
-	ct.core.MessagesPanel.SelectedMessage = -1
-	ct.core.MessagesPanel.
-		Highlight().
-		Clear().
-		SetTitle("")
-	ct.core.MessageInput.SetText("")
-
-	ref := node.GetReference()
-	c, err := ct.core.State.Cabinet.Channel(ref.(discord.ChannelID))
-	if err != nil {
-		return
-	}
-
-	// If the channel is a category channel, expand the selected node if it is collapsed, otherwise collapse.
-	if c.Type == discord.GuildCategory {
-		node.SetExpanded(!node.IsExpanded())
-		return
-	}
-
-	ct.SelectedChannel = c
-	ct.core.App.SetFocus(ct.core.MessageInput)
-
-	title := channelToString(*c)
-	if c.Topic != "" {
-		title += " - " + parseMarkdown(c.Topic)
-	}
-	ct.core.MessagesPanel.SetTitle(title)
-
-	go func() {
-		// The returned slice will be sorted from latest to oldest.
-		ms, err := ct.core.State.Messages(c.ID, ct.core.Config.MessagesLimit)
-		if err != nil {
-			return
-		}
-
-		for i := len(ms) - 1; i >= 0; i-- {
-			_, err = ct.core.MessagesPanel.Write(buildMessage(ct.core, ms[i]))
-			if err != nil {
-				return
-			}
-		}
-
-		ct.core.MessagesPanel.ScrollToEnd()
-	}()
-}
-
-func (ct *ChannelsTree) createChannelNode(c discord.Channel) *tview.TreeNode {
-	channelNode := tview.NewTreeNode(channelToString(c))
-	channelNode.SetReference(c.ID)
-
-	return channelNode
-}
-
-func (ct *ChannelsTree) createPrivateChannelNodes(rootNode *tview.TreeNode) {
-	cs, err := ct.core.State.Cabinet.PrivateChannels()
-	if err != nil {
-		return
-	}
-
-	sort.Slice(cs, func(i, j int) bool {
-		return cs[i].LastMessageID > cs[j].LastMessageID
-	})
-
-	for _, c := range cs {
-		rootNode.AddChild(ct.createChannelNode(c))
-	}
-}
-
-func (ct *ChannelsTree) createGuildChannelNodes(rootNode *tview.TreeNode, gID discord.GuildID) {
-	cs, err := ct.core.State.Cabinet.Channels(gID)
-	if err != nil {
-		return
-	}
-
-	sort.Slice(cs, func(i, j int) bool {
-		return cs[i].Position < cs[j].Position
-	})
-
-	ct.createOrphanChannelNodes(rootNode, cs)
-	ct.createCategoryChannelNodes(rootNode, cs)
-	ct.createChildrenChannelNodes(rootNode, cs)
-}
-
-func (ct *ChannelsTree) createOrphanChannelNodes(rootNode *tview.TreeNode, cs []discord.Channel) {
-	for _, c := range cs {
-		if (c.Type == discord.GuildText || c.Type == discord.GuildNews) && (!c.ParentID.IsValid()) {
-			rootNode.AddChild(ct.createChannelNode(c))
-		}
-	}
-}
-
-func (ct *ChannelsTree) createCategoryChannelNodes(rootNode *tview.TreeNode, cs []discord.Channel) {
-CATEGORY:
-	for _, c := range cs {
-		if c.Type == discord.GuildCategory {
-			for _, nestedChannel := range cs {
-				if nestedChannel.ParentID == c.ID {
-					rootNode.AddChild(ct.createChannelNode(c))
-					continue CATEGORY
-				}
-			}
-
-			rootNode.AddChild(ct.createChannelNode(c))
-		}
-	}
-}
-
-func (ct *ChannelsTree) createChildrenChannelNodes(rootNode *tview.TreeNode, cs []discord.Channel) {
-	for _, c := range cs {
-		if (c.Type == discord.GuildText || c.Type == discord.GuildNews) && (c.ParentID.IsValid()) {
-			var parentNode *tview.TreeNode
-			rootNode.Walk(func(node, _ *tview.TreeNode) bool {
-				if node.GetReference() == c.ParentID {
-					parentNode = node
-					return false
-				}
-
-				return true
-			})
-
-			if parentNode != nil {
-				parentNode.AddChild(ct.createChannelNode(c))
-			}
-		}
-	}
-}

+ 161 - 0
ui/channels_view.go

@@ -0,0 +1,161 @@
+package ui
+
+import (
+	"sort"
+
+	"github.com/diamondburned/arikawa/v3/discord"
+	"github.com/rivo/tview"
+)
+
+type ChannelsView struct {
+	*tview.TreeView
+	selectedChannel *discord.Channel
+	core            *Core
+}
+
+func newChannelsView(c *Core) *ChannelsView {
+	v := &ChannelsView{
+		TreeView: tview.NewTreeView(),
+		core:     c,
+	}
+
+	v.SetRoot(tview.NewTreeNode(""))
+	v.SetTopLevel(1)
+	v.SetSelectedFunc(v.onSelected)
+
+	v.SetTitle("Channels")
+	v.SetTitleAlign(tview.AlignLeft)
+	v.SetBorder(true)
+	v.SetBorderPadding(0, 0, 1, 1)
+
+	return v
+}
+
+func (v *ChannelsView) onSelected(node *tview.TreeNode) {
+	v.selectedChannel = nil
+	v.core.MessagesView.selectedMessage = -1
+	v.core.MessagesView.
+		Highlight().
+		Clear().
+		SetTitle("")
+	v.core.InputView.SetText("")
+
+	ref := node.GetReference()
+	c, err := v.core.State.Cabinet.Channel(ref.(discord.ChannelID))
+	if err != nil {
+		return
+	}
+
+	// If the channel is a category channel, expand the selected node if it is collapsed, otherwise collapse.
+	if c.Type == discord.GuildCategory {
+		node.SetExpanded(!node.IsExpanded())
+		return
+	}
+
+	v.selectedChannel = c
+	v.core.App.SetFocus(v.core.InputView)
+
+	title := channelToString(*c)
+	if c.Topic != "" {
+		title += " - " + parseMarkdown(c.Topic)
+	}
+	v.core.MessagesView.SetTitle(title)
+
+	go func() {
+		// The returned slice will be sorted from latest to oldest.
+		ms, err := v.core.State.Messages(c.ID, v.core.Config.MessagesLimit)
+		if err != nil {
+			return
+		}
+
+		for i := len(ms) - 1; i >= 0; i-- {
+			_, err = v.core.MessagesView.Write(buildMessage(v.core, ms[i]))
+			if err != nil {
+				return
+			}
+		}
+
+		v.core.MessagesView.ScrollToEnd()
+	}()
+}
+
+func (v *ChannelsView) createChannelNode(c discord.Channel) *tview.TreeNode {
+	channelNode := tview.NewTreeNode(channelToString(c))
+	channelNode.SetReference(c.ID)
+
+	return channelNode
+}
+
+func (v *ChannelsView) createPrivateChannelNodes(root *tview.TreeNode) {
+	cs, err := v.core.State.Cabinet.PrivateChannels()
+	if err != nil {
+		return
+	}
+
+	sort.Slice(cs, func(i, j int) bool {
+		return cs[i].LastMessageID > cs[j].LastMessageID
+	})
+
+	for _, c := range cs {
+		root.AddChild(v.createChannelNode(c))
+	}
+}
+
+func (v *ChannelsView) createGuildChannelNodes(root *tview.TreeNode, gID discord.GuildID) {
+	cs, err := v.core.State.Cabinet.Channels(gID)
+	if err != nil {
+		return
+	}
+
+	sort.Slice(cs, func(i, j int) bool {
+		return cs[i].Position < cs[j].Position
+	})
+
+	v.createOrphanChannelNodes(root, cs)
+	v.createCategoryChannelNodes(root, cs)
+	v.createChildrenChannelNodes(root, cs)
+}
+
+func (v *ChannelsView) createOrphanChannelNodes(root *tview.TreeNode, cs []discord.Channel) {
+	for _, c := range cs {
+		if (c.Type == discord.GuildText || c.Type == discord.GuildNews) && (!c.ParentID.IsValid()) {
+			root.AddChild(v.createChannelNode(c))
+		}
+	}
+}
+
+func (v *ChannelsView) createCategoryChannelNodes(root *tview.TreeNode, cs []discord.Channel) {
+CATEGORY:
+	for _, c := range cs {
+		if c.Type == discord.GuildCategory {
+			for _, nestedChannel := range cs {
+				if nestedChannel.ParentID == c.ID {
+					root.AddChild(v.createChannelNode(c))
+					continue CATEGORY
+				}
+			}
+
+			root.AddChild(v.createChannelNode(c))
+		}
+	}
+}
+
+func (v *ChannelsView) createChildrenChannelNodes(root *tview.TreeNode, cs []discord.Channel) {
+	for _, c := range cs {
+		if (c.Type == discord.GuildText || c.Type == discord.GuildNews) && (c.ParentID.IsValid()) {
+			var parentNode *tview.TreeNode
+			root.Walk(func(node, _ *tview.TreeNode) bool {
+				if node.GetReference() == c.ParentID {
+					parentNode = node
+					return false
+				}
+
+				return true
+			})
+
+			if parentNode != nil {
+				parentNode.AddChild(v.createChannelNode(c))
+			}
+		}
+	}
+}

+ 45 - 45
ui/core.go

@@ -15,10 +15,10 @@ import (
 type FocusedID int
 
 const (
-	guildsTree FocusedID = iota
-	channelsTree
-	messagesPanel
-	messageInput
+	guildsView FocusedID = iota
+	channelsView
+	messagesView
+	inputView
 )
 
 // Core is responsible for the following:
@@ -26,12 +26,12 @@ const (
 // - Configuration of the application and state when Run is called.
 // - Management of the application and state.
 type Core struct {
-	App           *tview.Application
-	View          *tview.Flex
-	GuildsTree    *GuildsTree
-	ChannelsTree  *ChannelsTree
-	MessagesPanel *MessagesPanel
-	MessageInput  *MessageInput
+	App          *tview.Application
+	View         *tview.Flex
+	GuildsView   *GuildsView
+	ChannelsView *ChannelsView
+	MessagesView *MessagesView
+	InputView    *InputView
 
 	Config *config.Config
 	State  *state.State
@@ -55,10 +55,10 @@ func NewCore(cfg *config.Config) *Core {
 	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)
+	c.GuildsView = newGuildsView(c)
+	c.ChannelsView = newChannelsView(c)
+	c.MessagesView = newMessagesView(c)
+	c.InputView = newInputView(c)
 
 	return c
 }
@@ -75,18 +75,18 @@ func (c *Core) Run(token string) error {
 func (c *Core) Draw() {
 	left := tview.NewFlex().
 		SetDirection(tview.FlexRow).
-		AddItem(c.GuildsTree, 10, 1, false).
-		AddItem(c.ChannelsTree, 0, 1, false)
+		AddItem(c.GuildsView, 10, 1, false).
+		AddItem(c.ChannelsView, 0, 1, false)
 	right := tview.NewFlex().
 		SetDirection(tview.FlexRow).
-		AddItem(c.MessagesPanel, 0, 1, false).
-		AddItem(c.MessageInput, 3, 1, false)
+		AddItem(c.MessagesView, 0, 1, false).
+		AddItem(c.InputView, 3, 1, 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)
+	c.App.SetFocus(c.GuildsView)
 }
 
 func (c *Core) onAppBeforeDraw(screen tcell.Screen) bool {
@@ -102,18 +102,18 @@ func (c *Core) onViewInputCapture(event *tcell.EventKey) *tcell.EventKey {
 	case tcell.KeyEsc:
 		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 the currently focused view is the guilds view (first), then focus the input view (last)
 		if c.focusedID == 0 {
-			c.focusedID = messageInput
+			c.focusedID = inputView
 		} else {
 			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.focusedID == messageInput {
-			c.focusedID = guildsTree
+		// If the currently focused view is the input view (last), then focus the guilds view (first)
+		if c.focusedID == inputView {
+			c.focusedID = guildsView
 		} else {
 			c.focusedID++
 		}
@@ -127,21 +127,21 @@ func (c *Core) onViewInputCapture(event *tcell.EventKey) *tcell.EventKey {
 func (c *Core) setFocus() {
 	var p tview.Primitive
 	switch c.focusedID {
-	case guildsTree:
-		p = c.GuildsTree
-	case channelsTree:
-		p = c.ChannelsTree
-	case messagesPanel:
-		p = c.MessagesPanel
-	case messageInput:
-		p = c.MessageInput
+	case guildsView:
+		p = c.GuildsView
+	case channelsView:
+		p = c.ChannelsView
+	case messagesView:
+		p = c.MessagesView
+	case inputView:
+		p = c.InputView
 	}
 
 	c.App.SetFocus(p)
 }
 
 func (c *Core) onReady(r *gateway.ReadyEvent) {
-	rootNode := c.GuildsTree.GetRoot()
+	root := c.GuildsView.GetRoot()
 	for _, gf := range r.UserSettings.GuildFolders {
 		if gf.ID == 0 {
 			for _, gID := range gf.GuildIDs {
@@ -152,7 +152,7 @@ func (c *Core) onReady(r *gateway.ReadyEvent) {
 
 				guildNode := tview.NewTreeNode(g.Name)
 				guildNode.SetReference(g.ID)
-				rootNode.AddChild(guildNode)
+				root.AddChild(guildNode)
 			}
 		} else {
 			var b strings.Builder
@@ -174,7 +174,7 @@ func (c *Core) onReady(r *gateway.ReadyEvent) {
 			b.WriteString("[-]")
 
 			folderNode := tview.NewTreeNode(b.String())
-			rootNode.AddChild(folderNode)
+			root.AddChild(folderNode)
 
 			for _, gID := range gf.GuildIDs {
 				g, err := c.State.Cabinet.Guild(gID)
@@ -190,24 +190,24 @@ func (c *Core) onReady(r *gateway.ReadyEvent) {
 
 	}
 
-	c.GuildsTree.SetCurrentNode(rootNode)
-	c.App.SetFocus(c.GuildsTree)
+	c.GuildsView.SetCurrentNode(root)
+	c.App.SetFocus(c.GuildsView)
 }
 
 func (c *Core) onGuildCreate(g *gateway.GuildCreateEvent) {
 	guildNode := tview.NewTreeNode(g.Name)
 	guildNode.SetReference(g.ID)
 
-	rootNode := c.GuildsTree.GetRoot()
+	rootNode := c.GuildsView.GetRoot()
 	rootNode.AddChild(guildNode)
 
-	c.GuildsTree.SetCurrentNode(rootNode)
-	c.App.SetFocus(c.GuildsTree)
+	c.GuildsView.SetCurrentNode(rootNode)
+	c.App.SetFocus(c.GuildsView)
 	c.App.Draw()
 }
 
 func (c *Core) onGuildDelete(g *gateway.GuildDeleteEvent) {
-	rootNode := c.GuildsTree.GetRoot()
+	rootNode := c.GuildsView.GetRoot()
 	var parentNode *tview.TreeNode
 	rootNode.Walk(func(node, _ *tview.TreeNode) bool {
 		if node.GetReference() == g.ID {
@@ -226,14 +226,14 @@ func (c *Core) onGuildDelete(g *gateway.GuildDeleteEvent) {
 }
 
 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 c.ChannelsView.selectedChannel != nil && c.ChannelsView.selectedChannel.ID == m.ChannelID {
+		_, err := c.MessagesView.Write(buildMessage(c, m.Message))
 		if err != nil {
 			return
 		}
 
-		if len(c.MessagesPanel.GetHighlights()) == 0 {
-			c.MessagesPanel.ScrollToEnd()
+		if len(c.MessagesView.GetHighlights()) == 0 {
+			c.MessagesView.ScrollToEnd()
 		}
 	}
 }

+ 0 - 61
ui/guilds_tree.go

@@ -1,61 +0,0 @@
-package ui
-
-import (
-	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/rivo/tview"
-)
-
-type GuildsTree struct {
-	*tview.TreeView
-	core *Core
-}
-
-func NewGuildsTree(c *Core) *GuildsTree {
-	gt := &GuildsTree{
-		TreeView: tview.NewTreeView(),
-		core:     c,
-	}
-
-	rootNode := tview.NewTreeNode("")
-	rootNode.AddChild(tview.NewTreeNode("Direct Messages"))
-
-	gt.SetRoot(rootNode)
-	gt.SetTopLevel(1)
-	gt.SetSelectedFunc(gt.onSelected)
-
-	gt.SetTitle("Guilds")
-	gt.SetTitleAlign(tview.AlignLeft)
-	gt.SetBorder(true)
-	gt.SetBorderPadding(0, 0, 1, 1)
-
-	return gt
-}
-
-func (gt *GuildsTree) onSelected(node *tview.TreeNode) {
-	gt.core.ChannelsTree.SelectedChannel = nil
-	gt.core.MessagesPanel.SelectedMessage = -1
-	rootNode := gt.core.ChannelsTree.GetRoot()
-	rootNode.ClearChildren()
-	gt.core.MessagesPanel.
-		Highlight().
-		Clear().
-		SetTitle("")
-	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 {
-		node.SetExpanded(!node.IsExpanded())
-		return
-	}
-
-	ref := node.GetReference()
-	// If the reference of the selected node is nil, it must be the direct messages node.
-	if ref == nil {
-		gt.core.ChannelsTree.createPrivateChannelNodes(rootNode)
-	} else { // Guild
-		gt.core.ChannelsTree.createGuildChannelNodes(rootNode, ref.(discord.GuildID))
-	}
-
-	gt.core.ChannelsTree.SetCurrentNode(rootNode)
-	gt.core.App.SetFocus(gt.core.ChannelsTree)
-}

+ 61 - 0
ui/guilds_view.go

@@ -0,0 +1,61 @@
+package ui
+
+import (
+	"github.com/diamondburned/arikawa/v3/discord"
+	"github.com/rivo/tview"
+)
+
+type GuildsView struct {
+	*tview.TreeView
+	core *Core
+}
+
+func newGuildsView(c *Core) *GuildsView {
+	v := &GuildsView{
+		TreeView: tview.NewTreeView(),
+		core:     c,
+	}
+
+	root := tview.NewTreeNode("")
+	root.AddChild(tview.NewTreeNode("Direct Messages"))
+
+	v.SetRoot(root)
+	v.SetTopLevel(1)
+	v.SetSelectedFunc(v.onSelected)
+
+	v.SetTitle("Guilds")
+	v.SetTitleAlign(tview.AlignLeft)
+	v.SetBorder(true)
+	v.SetBorderPadding(0, 0, 1, 1)
+
+	return v
+}
+
+func (v *GuildsView) onSelected(node *tview.TreeNode) {
+	v.core.ChannelsView.selectedChannel = nil
+	v.core.MessagesView.selectedMessage = -1
+	rootNode := v.core.ChannelsView.GetRoot()
+	rootNode.ClearChildren()
+	v.core.MessagesView.
+		Highlight().
+		Clear().
+		SetTitle("")
+	v.core.InputView.SetText("")
+
+	// If the selected node has children (guild folder), expand the selected node if it is collapsed, otherwise collapse.
+	if len(node.GetChildren()) != 0 {
+		node.SetExpanded(!node.IsExpanded())
+		return
+	}
+
+	ref := node.GetReference()
+	// If the reference of the selected node is nil, it must be the direct messages node.
+	if ref == nil {
+		v.core.ChannelsView.createPrivateChannelNodes(rootNode)
+	} else { // Guild
+		v.core.ChannelsView.createGuildChannelNodes(rootNode, ref.(discord.GuildID))
+	}
+
+	v.core.ChannelsView.SetCurrentNode(rootNode)
+	v.core.App.SetFocus(v.core.ChannelsView)
+}

+ 144 - 0
ui/input_view.go

@@ -0,0 +1,144 @@
+package ui
+
+import (
+	"io"
+	"os"
+	"os/exec"
+	"strings"
+
+	"github.com/atotto/clipboard"
+	"github.com/diamondburned/arikawa/v3/api"
+	"github.com/diamondburned/arikawa/v3/discord"
+	"github.com/diamondburned/arikawa/v3/utils/json/option"
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+)
+
+type InputView struct {
+	*tview.InputField
+	core *Core
+}
+
+func newInputView(c *Core) *InputView {
+	v := &InputView{
+		InputField: tview.NewInputField(),
+		core:       c,
+	}
+
+	v.SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
+	v.SetPlaceholder("Message...")
+	v.SetPlaceholderStyle(tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor))
+	v.SetInputCapture(v.inputCapture)
+
+	v.SetTitleAlign(tview.AlignLeft)
+	v.SetBorder(true)
+	v.SetBorderPadding(0, 0, 1, 1)
+
+	return v
+}
+
+func (v *InputView) inputCapture(event *tcell.EventKey) *tcell.EventKey {
+	switch event.Name() {
+	case "Enter":
+		return v.sendMessage()
+	case v.core.Config.Keys.InputView.OpenExternalEditor:
+		return v.openExternalEditor()
+	case v.core.Config.Keys.InputView.PasteClipboard:
+		return v.pasteClipboard()
+	case "Esc":
+		v.
+			SetText("").
+			SetTitle("")
+		v.core.MessagesView.selectedMessage = -1
+		v.core.MessagesView.Highlight()
+		return nil
+	}
+
+	return event
+}
+
+func (v *InputView) sendMessage() *tcell.EventKey {
+	if v.core.ChannelsView.selectedChannel == nil {
+		return nil
+	}
+
+	t := strings.TrimSpace(v.GetText())
+	if t == "" {
+		return nil
+	}
+
+	ms, err := v.core.State.Messages(v.core.ChannelsView.selectedChannel.ID, v.core.Config.MessagesLimit)
+	if err != nil {
+		return nil
+	}
+
+	if len(v.core.MessagesView.GetHighlights()) != 0 {
+		mID, err := discord.ParseSnowflake(v.core.MessagesView.GetHighlights()[0])
+		if err != nil {
+			return nil
+		}
+
+		_, m := findMessageByID(ms, discord.MessageID(mID))
+		d := api.SendMessageData{
+			Content:         t,
+			Reference:       m.Reference,
+			AllowedMentions: &api.AllowedMentions{RepliedUser: option.False},
+		}
+
+		// If the title of the input view has "[@]" as a prefix, send the message as a reply and mention the replied user.
+		if strings.HasPrefix(v.GetTitle(), "[@]") {
+			d.AllowedMentions.RepliedUser = option.True
+		}
+
+		go v.core.State.SendMessageComplex(m.ChannelID, d)
+
+		v.core.MessagesView.selectedMessage = -1
+		v.core.MessagesView.Highlight()
+
+		v.SetTitle("")
+	} else {
+		go v.core.State.SendMessage(v.core.ChannelsView.selectedChannel.ID, t)
+	}
+
+	v.SetText("")
+	return nil
+}
+
+func (v *InputView) pasteClipboard() *tcell.EventKey {
+	text, _ := clipboard.ReadAll()
+	text = v.GetText() + text
+	v.SetText(text)
+	return nil
+}
+
+func (v *InputView) openExternalEditor() *tcell.EventKey {
+	e := os.Getenv("EDITOR")
+	if e == "" {
+		return nil
+	}
+
+	f, err := os.CreateTemp(os.TempDir(), "discordo-*.md")
+	if err != nil {
+		return nil
+	}
+	defer os.Remove(f.Name())
+
+	cmd := exec.Command(e, f.Name())
+	cmd.Stdin = os.Stdin
+	cmd.Stdout = os.Stdout
+
+	v.core.App.Suspend(func() {
+		err = cmd.Run()
+		if err != nil {
+			return
+		}
+	})
+
+	b, err := io.ReadAll(f)
+	if err != nil {
+		return nil
+	}
+
+	v.SetText(string(b))
+	return nil
+}

+ 0 - 229
ui/message_actions_list.go

@@ -1,229 +0,0 @@
-package ui
-
-import (
-	"io"
-	"net/http"
-	"os"
-	"path/filepath"
-	"regexp"
-
-	"github.com/atotto/clipboard"
-	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/rivo/tview"
-	"github.com/skratchdot/open-golang/open"
-)
-
-var linkRegex = regexp.MustCompile("https?://.+")
-
-type MessageActionsList struct {
-	*tview.List
-	core    *Core
-	message *discord.Message
-}
-
-func NewMessageActionsList(c *Core, m *discord.Message) *MessageActionsList {
-	mal := &MessageActionsList{
-		List:    tview.NewList(),
-		core:    c,
-		message: m,
-	}
-
-	mal.ShowSecondaryText(false)
-	mal.SetDoneFunc(func() {
-		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.
-	if hasPermission(c.State, c.ChannelsTree.SelectedChannel.ID, discord.PermissionSendMessages) {
-		mal.AddItem("Reply", "", 'r', mal.replyAction)
-		mal.AddItem("Mention Reply", "", 'R', mal.mentionReplyAction)
-	}
-
-	// If the referenced message exists, add a new action to select the reply.
-	if m.ReferencedMessage != nil {
-		mal.AddItem("Select Reply", "", 'm', mal.selectReplyAction)
-	}
-
-	// If the content of the message contains link(s), add the appropriate actions to the list.
-	links := linkRegex.FindAllString(m.Content, -1)
-	if len(links) != 0 {
-		mal.AddItem("Open Link", "", 'l', func() {
-			for _, l := range links {
-				go open.Run(l)
-			}
-
-			c.App.SetRoot(c.View, true)
-			c.App.SetFocus(c.MessagesPanel)
-		})
-	}
-
-	// If the message contains attachments, add the appropriate actions to the actions list.
-	if len(m.Attachments) != 0 {
-		mal.AddItem("Open Attachment", "", 'o', mal.openAttachmentAction)
-		mal.AddItem("Download Attachment", "", 'd', mal.downloadAttachmentAction)
-	}
-
-	// If the client user has the `MANAGE_MESSAGES` permission, add a new action to delete the message.
-	if hasPermission(c.State, c.ChannelsTree.SelectedChannel.ID, discord.PermissionManageMessages) {
-		mal.AddItem("Delete", "", 'd', mal.deleteAction)
-	}
-
-	mal.AddItem("Copy Content", "", 'c', mal.copyContentAction)
-	mal.AddItem("Copy ID", "", 'i', mal.copyIDAction)
-	mal.AddItem("Copy Link", "", 'k', mal.copyLinkAction)
-
-	mal.SetTitle("Press the Escape key to close")
-	mal.SetTitleAlign(tview.AlignLeft)
-	mal.SetBorder(true)
-	mal.SetBorderPadding(0, 0, 1, 1)
-
-	return mal
-}
-
-func (mal *MessageActionsList) replyAction() {
-	mal.core.MessageInput.SetTitle("Replying to " + mal.message.Author.Tag())
-
-	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.App.SetRoot(mal.core.View, true)
-	mal.core.App.SetFocus(mal.core.MessageInput)
-}
-
-func (mal *MessageActionsList) selectReplyAction() {
-	ms, err := mal.core.State.Cabinet.Messages(mal.message.ChannelID)
-	if err != nil {
-		return
-	}
-
-	mal.core.MessagesPanel.SelectedMessage, _ = findMessageByID(ms, mal.message.ReferencedMessage.ID)
-	mal.core.MessagesPanel.
-		Highlight(mal.message.ReferencedMessage.ID.String()).
-		ScrollToHighlight()
-
-	mal.core.App.SetRoot(mal.core.View, true)
-	mal.core.App.SetFocus(mal.core.MessagesPanel)
-}
-
-func (mal *MessageActionsList) openAttachmentAction() {
-	for _, a := range mal.message.Attachments {
-		cacheDirPath, _ := os.UserCacheDir()
-		f, err := os.Create(filepath.Join(cacheDirPath, a.Filename))
-		if err != nil {
-			return
-		}
-		defer f.Close()
-
-		resp, err := http.Get(a.URL)
-		if err != nil {
-			return
-		}
-
-		d, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return
-		}
-
-		f.Write(d)
-		go open.Run(f.Name())
-	}
-
-	mal.core.App.SetRoot(mal.core.View, true)
-	mal.core.App.SetFocus(mal.core.MessagesPanel)
-}
-
-func (mal *MessageActionsList) downloadAttachmentAction() {
-	for _, a := range mal.message.Attachments {
-		path, err := os.UserHomeDir()
-		if err != nil {
-			path = os.TempDir()
-		}
-
-		path = filepath.Join(path, "Downloads", a.Filename)
-		f, err := os.Create(path)
-		if err != nil {
-			return
-		}
-		defer f.Close()
-
-		resp, err := http.Get(a.URL)
-		if err != nil {
-			return
-		}
-
-		d, err := io.ReadAll(resp.Body)
-		if err != nil {
-			return
-		}
-
-		f.Write(d)
-	}
-
-	mal.core.App.SetRoot(mal.core.View, true)
-	mal.core.App.SetFocus(mal.core.MessagesPanel)
-}
-
-func (mal *MessageActionsList) deleteAction() {
-	mal.core.MessagesPanel.Clear()
-
-	err := mal.core.State.MessageRemove(mal.message.ChannelID, mal.message.ID)
-	if err != nil {
-		return
-	}
-
-	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.core.State.Cabinet.Messages(mal.message.ChannelID)
-	if err != nil {
-		return
-	}
-
-	for i := len(ms) - 1; i >= 0; i-- {
-		_, err = mal.core.MessagesPanel.Write(buildMessage(mal.core, ms[i]))
-		if err != nil {
-			return
-		}
-	}
-
-	mal.core.App.SetRoot(mal.core.View, true)
-	mal.core.App.SetFocus(mal.core.MessagesPanel)
-}
-
-func (mal *MessageActionsList) copyContentAction() {
-	err := clipboard.WriteAll(mal.message.Content)
-	if err != nil {
-		return
-	}
-
-	mal.core.App.SetRoot(mal.core.View, true)
-	mal.core.App.SetFocus(mal.core.MessagesPanel)
-}
-
-func (mal *MessageActionsList) copyIDAction() {
-	err := clipboard.WriteAll(mal.message.ID.String())
-	if err != nil {
-		return
-	}
-
-	mal.core.App.SetRoot(mal.core.View, true)
-	mal.core.App.SetFocus(mal.core.MessagesPanel)
-}
-
-func (mal *MessageActionsList) copyLinkAction() {
-	err := clipboard.WriteAll(mal.message.URL())
-	if err != nil {
-		return
-	}
-
-	mal.core.App.SetRoot(mal.core.View, true)
-	mal.core.App.SetFocus(mal.core.MessagesPanel)
-}

+ 0 - 144
ui/message_input.go

@@ -1,144 +0,0 @@
-package ui
-
-import (
-	"io"
-	"os"
-	"os/exec"
-	"strings"
-
-	"github.com/atotto/clipboard"
-	"github.com/diamondburned/arikawa/v3/api"
-	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/diamondburned/arikawa/v3/utils/json/option"
-	"github.com/gdamore/tcell/v2"
-	"github.com/rivo/tview"
-)
-
-type MessageInput struct {
-	*tview.InputField
-	core *Core
-}
-
-func NewMessageInput(c *Core) *MessageInput {
-	mi := &MessageInput{
-		InputField: tview.NewInputField(),
-		core:       c,
-	}
-
-	mi.SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
-	mi.SetPlaceholder("Message...")
-	mi.SetPlaceholderStyle(tcell.StyleDefault.Background(tview.Styles.PrimitiveBackgroundColor))
-	mi.SetInputCapture(mi.inputCapture)
-
-	mi.SetTitleAlign(tview.AlignLeft)
-	mi.SetBorder(true)
-	mi.SetBorderPadding(0, 0, 1, 1)
-
-	return mi
-}
-
-func (mi *MessageInput) inputCapture(event *tcell.EventKey) *tcell.EventKey {
-	switch event.Name() {
-	case "Enter":
-		return mi.sendMessage()
-	case mi.core.Config.Keys.MessageInput.OpenExternalEditor:
-		return mi.openExternalEditor()
-	case mi.core.Config.Keys.MessageInput.PasteClipboard:
-		return mi.pasteClipboard()
-	case "Esc":
-		mi.
-			SetText("").
-			SetTitle("")
-		mi.core.MessagesPanel.SelectedMessage = -1
-		mi.core.MessagesPanel.Highlight()
-		return nil
-	}
-
-	return event
-}
-
-func (mi *MessageInput) sendMessage() *tcell.EventKey {
-	if mi.core.ChannelsTree.SelectedChannel == nil {
-		return nil
-	}
-
-	t := strings.TrimSpace(mi.GetText())
-	if t == "" {
-		return nil
-	}
-
-	ms, err := mi.core.State.Messages(mi.core.ChannelsTree.SelectedChannel.ID, mi.core.Config.MessagesLimit)
-	if err != nil {
-		return nil
-	}
-
-	if len(mi.core.MessagesPanel.GetHighlights()) != 0 {
-		mID, err := discord.ParseSnowflake(mi.core.MessagesPanel.GetHighlights()[0])
-		if err != nil {
-			return nil
-		}
-
-		_, m := findMessageByID(ms, discord.MessageID(mID))
-		d := api.SendMessageData{
-			Content:         t,
-			Reference:       m.Reference,
-			AllowedMentions: &api.AllowedMentions{RepliedUser: option.False},
-		}
-
-		// If the title of the message InputField widget has "[@]" as a prefix, send the message as a reply and mention the replied user.
-		if strings.HasPrefix(mi.GetTitle(), "[@]") {
-			d.AllowedMentions.RepliedUser = option.True
-		}
-
-		go mi.core.State.SendMessageComplex(m.ChannelID, d)
-
-		mi.core.MessagesPanel.SelectedMessage = -1
-		mi.core.MessagesPanel.Highlight()
-
-		mi.SetTitle("")
-	} else {
-		go mi.core.State.SendMessage(mi.core.ChannelsTree.SelectedChannel.ID, t)
-	}
-
-	mi.SetText("")
-	return nil
-}
-
-func (mi *MessageInput) pasteClipboard() *tcell.EventKey {
-	text, _ := clipboard.ReadAll()
-	text = mi.GetText() + text
-	mi.SetText(text)
-	return nil
-}
-
-func (mi *MessageInput) openExternalEditor() *tcell.EventKey {
-	e := os.Getenv("EDITOR")
-	if e == "" {
-		return nil
-	}
-
-	f, err := os.CreateTemp(os.TempDir(), "discordo-*.md")
-	if err != nil {
-		return nil
-	}
-	defer os.Remove(f.Name())
-
-	cmd := exec.Command(e, f.Name())
-	cmd.Stdin = os.Stdin
-	cmd.Stdout = os.Stdout
-
-	mi.core.App.Suspend(func() {
-		err = cmd.Run()
-		if err != nil {
-			return
-		}
-	})
-
-	b, err := io.ReadAll(f)
-	if err != nil {
-		return nil
-	}
-
-	mi.SetText(string(b))
-	return nil
-}

+ 0 - 150
ui/messages_panel.go

@@ -1,150 +0,0 @@
-package ui
-
-import (
-	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/gdamore/tcell/v2"
-	"github.com/rivo/tview"
-)
-
-type MessagesPanel struct {
-	*tview.TextView
-	// The index of the currently selected message. A negative index indicates that there is no currently selected message.
-	SelectedMessage int
-
-	core *Core
-}
-
-func NewMessagesPanel(c *Core) *MessagesPanel {
-	mp := &MessagesPanel{
-		TextView:        tview.NewTextView(),
-		SelectedMessage: -1,
-
-		core: c,
-	}
-
-	mp.SetDynamicColors(true)
-	mp.SetRegions(true)
-	mp.SetWordWrap(true)
-	mp.SetInputCapture(mp.onInputCapture)
-	mp.SetChangedFunc(func() {
-		mp.core.App.Draw()
-	})
-
-	mp.SetTitle("Messages")
-	mp.SetTitleAlign(tview.AlignLeft)
-	mp.SetBorder(true)
-	mp.SetBorderPadding(0, 0, 1, 1)
-
-	return mp
-}
-
-func (mp *MessagesPanel) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
-	if mp.core.ChannelsTree.SelectedChannel == nil {
-		return nil
-	}
-
-	// 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 nil
-	}
-
-	// Defaults
-	switch e.Name() {
-	case mp.core.Config.Keys.MessagesPanel.OpenActionsList:
-		return mp.openMessageActionsList(ms)
-
-	case mp.core.Config.Keys.MessagesPanel.SelectPreviousMessage:
-		return mp.selectPreviousMessage(ms)
-	case mp.core.Config.Keys.MessagesPanel.SelectNextMessage:
-		return mp.selectNextMessage(ms)
-	case mp.core.Config.Keys.MessagesPanel.SelectFirstMessage:
-		return mp.selectFirstMessage(ms)
-	case mp.core.Config.Keys.MessagesPanel.SelectLastMessage:
-		return mp.selectLastMessage(ms)
-	case "Esc":
-		mp.SelectedMessage = -1
-		mp.core.App.SetFocus(mp.core.View)
-		mp.
-			Clear().
-			Highlight().
-			SetTitle("")
-		return nil
-	}
-
-	return e
-}
-
-func (mp *MessagesPanel) selectPreviousMessage(ms []discord.Message) *tcell.EventKey {
-	// If there are no highlighted regions, select the latest (last) message in the messages panel.
-	if len(mp.GetHighlights()) == 0 {
-		mp.SelectedMessage = 0
-	} else {
-		// If the selected message is the oldest (first) message, select the latest (last) message in the messages panel.
-		if mp.SelectedMessage == len(ms)-1 {
-			mp.SelectedMessage = 0
-		} else {
-			mp.SelectedMessage++
-		}
-	}
-
-	mp.Highlight(ms[mp.SelectedMessage].ID.String())
-	mp.ScrollToHighlight()
-	return nil
-}
-
-func (mp *MessagesPanel) selectNextMessage(ms []discord.Message) *tcell.EventKey {
-	// If there are no highlighted regions, select the latest (last) message in the messages panel.
-	if len(mp.GetHighlights()) == 0 {
-		mp.SelectedMessage = 0
-	} else {
-		// If the selected message is the latest (last) message, select the oldest (first) message in the messages panel.
-		if mp.SelectedMessage == 0 {
-			mp.SelectedMessage = len(ms) - 1
-		} else {
-			mp.SelectedMessage--
-		}
-	}
-
-	mp.
-		Highlight(ms[mp.SelectedMessage].ID.String()).
-		ScrollToHighlight()
-	return nil
-}
-
-func (mp *MessagesPanel) selectFirstMessage(ms []discord.Message) *tcell.EventKey {
-	mp.SelectedMessage = len(ms) - 1
-	mp.
-		Highlight(ms[mp.SelectedMessage].ID.String()).
-		ScrollToHighlight()
-	return nil
-}
-
-func (mp *MessagesPanel) selectLastMessage(ms []discord.Message) *tcell.EventKey {
-	mp.SelectedMessage = 0
-	mp.
-		Highlight(ms[mp.SelectedMessage].ID.String()).
-		ScrollToHighlight()
-	return nil
-}
-
-func (mp *MessagesPanel) openMessageActionsList(ms []discord.Message) *tcell.EventKey {
-	hs := mp.GetHighlights()
-	if len(hs) == 0 {
-		return nil
-	}
-
-	mID, err := discord.ParseSnowflake(hs[0])
-	if err != nil {
-		return nil
-	}
-
-	_, m := findMessageByID(ms, discord.MessageID(mID))
-	if m == nil {
-		return nil
-	}
-
-	actionsList := NewMessageActionsList(mp.core, m)
-	mp.core.App.SetRoot(actionsList, true)
-	return nil
-}

+ 148 - 0
ui/messages_view.go

@@ -0,0 +1,148 @@
+package ui
+
+import (
+	"github.com/diamondburned/arikawa/v3/discord"
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/tview"
+)
+
+type MessagesView struct {
+	*tview.TextView
+	// The index of the currently selected message. A negative index indicates that there is no currently selected message.
+	selectedMessage int
+	core            *Core
+}
+
+func newMessagesView(c *Core) *MessagesView {
+	v := &MessagesView{
+		TextView:        tview.NewTextView(),
+		selectedMessage: -1,
+		core:            c,
+	}
+
+	v.SetDynamicColors(true)
+	v.SetRegions(true)
+	v.SetWordWrap(true)
+	v.SetInputCapture(v.onInputCapture)
+	v.SetChangedFunc(func() {
+		v.core.App.Draw()
+	})
+
+	v.SetTitle("Messages")
+	v.SetTitleAlign(tview.AlignLeft)
+	v.SetBorder(true)
+	v.SetBorderPadding(0, 0, 1, 1)
+
+	return v
+}
+
+func (v *MessagesView) onInputCapture(e *tcell.EventKey) *tcell.EventKey {
+	if v.core.ChannelsView.selectedChannel == nil {
+		return nil
+	}
+
+	// Messages should return messages ordered from latest to earliest.
+	ms, err := v.core.State.Cabinet.Messages(v.core.ChannelsView.selectedChannel.ID)
+	if err != nil || len(ms) == 0 {
+		return nil
+	}
+
+	// Defaults
+	switch e.Name() {
+	case v.core.Config.Keys.MessagesView.OpenActionsView:
+		return v.openActionsView(ms)
+
+	case v.core.Config.Keys.MessagesView.SelectPreviousMessage:
+		return v.selectPreviousMessage(ms)
+	case v.core.Config.Keys.MessagesView.SelectNextMessage:
+		return v.selectNextMessage(ms)
+	case v.core.Config.Keys.MessagesView.SelectFirstMessage:
+		return v.selectFirstMessage(ms)
+	case v.core.Config.Keys.MessagesView.SelectLastMessage:
+		return v.selectLastMessage(ms)
+	case "Esc":
+		v.selectedMessage = -1
+		v.core.App.SetFocus(v.core.View)
+		v.
+			Clear().
+			Highlight().
+			SetTitle("")
+		return nil
+	}
+
+	return e
+}
+
+func (v *MessagesView) selectPreviousMessage(ms []discord.Message) *tcell.EventKey {
+	// If there are no highlighted regions, select the latest (last) message.
+	if len(v.GetHighlights()) == 0 {
+		v.selectedMessage = 0
+	} else {
+		// If the selected message is the oldest (first) message, select the latest (last) message.
+		if v.selectedMessage == len(ms)-1 {
+			v.selectedMessage = 0
+		} else {
+			v.selectedMessage++
+		}
+	}
+
+	v.Highlight(ms[v.selectedMessage].ID.String())
+	v.ScrollToHighlight()
+	return nil
+}
+
+func (v *MessagesView) selectNextMessage(ms []discord.Message) *tcell.EventKey {
+	// If there are no highlighted regions, select the latest (last) message.
+	if len(v.GetHighlights()) == 0 {
+		v.selectedMessage = 0
+	} else {
+		// If the selected message is the latest (last) message, select the oldest (first) message.
+		if v.selectedMessage == 0 {
+			v.selectedMessage = len(ms) - 1
+		} else {
+			v.selectedMessage--
+		}
+	}
+
+	v.
+		Highlight(ms[v.selectedMessage].ID.String()).
+		ScrollToHighlight()
+	return nil
+}
+
+func (v *MessagesView) selectFirstMessage(ms []discord.Message) *tcell.EventKey {
+	v.selectedMessage = len(ms) - 1
+	v.
+		Highlight(ms[v.selectedMessage].ID.String()).
+		ScrollToHighlight()
+	return nil
+}
+
+func (v *MessagesView) selectLastMessage(ms []discord.Message) *tcell.EventKey {
+	v.selectedMessage = 0
+	v.
+		Highlight(ms[v.selectedMessage].ID.String()).
+		ScrollToHighlight()
+	return nil
+}
+
+func (v *MessagesView) openActionsView(ms []discord.Message) *tcell.EventKey {
+	hs := v.GetHighlights()
+	if len(hs) == 0 {
+		return nil
+	}
+
+	mID, err := discord.ParseSnowflake(hs[0])
+	if err != nil {
+		return nil
+	}
+
+	_, m := findMessageByID(ms, discord.MessageID(mID))
+	if m == nil {
+		return nil
+	}
+
+	actionsView := newActionsView(v.core, m)
+	v.core.App.SetRoot(actionsView, true)
+	return nil
+}