From 0bbe6bf93a533418524dd1cca5af2a2ae65e939a Mon Sep 17 00:00:00 2001 From: ayntgl <90600421+ayntgl@users.noreply.github.com> Date: Mon, 26 Sep 2022 19:09:28 +0530 Subject: [PATCH] Rename inconsistent widget names (#224) --- config/config.go | 16 +-- ui/actions_view.go | 229 +++++++++++++++++++++++++++++++++++++ ui/channels_tree.go | 162 -------------------------- ui/channels_view.go | 161 ++++++++++++++++++++++++++ ui/core.go | 90 +++++++-------- ui/guilds_tree.go | 61 ---------- ui/guilds_view.go | 61 ++++++++++ ui/input_view.go | 144 +++++++++++++++++++++++ ui/message_actions_list.go | 229 ------------------------------------- ui/message_input.go | 144 ----------------------- ui/messages_panel.go | 150 ------------------------ ui/messages_view.go | 148 ++++++++++++++++++++++++ 12 files changed, 796 insertions(+), 799 deletions(-) create mode 100644 ui/actions_view.go delete mode 100644 ui/channels_tree.go create mode 100644 ui/channels_view.go delete mode 100644 ui/guilds_tree.go create mode 100644 ui/guilds_view.go create mode 100644 ui/input_view.go delete mode 100644 ui/message_actions_list.go delete mode 100644 ui/message_input.go delete mode 100644 ui/messages_panel.go create mode 100644 ui/messages_view.go diff --git a/config/config.go b/config/config.go index bad8e00..d341c8a 100644 --- a/config/config.go +++ b/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", diff --git a/ui/actions_view.go b/ui/actions_view.go new file mode 100644 index 0000000..c2d36ca --- /dev/null +++ b/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) +} diff --git a/ui/channels_tree.go b/ui/channels_tree.go deleted file mode 100644 index 0fe6c6f..0000000 --- a/ui/channels_tree.go +++ /dev/null @@ -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)) - } - } - } -} diff --git a/ui/channels_view.go b/ui/channels_view.go new file mode 100644 index 0000000..bbe2426 --- /dev/null +++ b/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)) + } + } + } +} diff --git a/ui/core.go b/ui/core.go index e2c7e4b..d32086a 100644 --- a/ui/core.go +++ b/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() } } } diff --git a/ui/guilds_tree.go b/ui/guilds_tree.go deleted file mode 100644 index c5f2522..0000000 --- a/ui/guilds_tree.go +++ /dev/null @@ -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) -} diff --git a/ui/guilds_view.go b/ui/guilds_view.go new file mode 100644 index 0000000..8976658 --- /dev/null +++ b/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) +} diff --git a/ui/input_view.go b/ui/input_view.go new file mode 100644 index 0000000..75173a4 --- /dev/null +++ b/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 +} diff --git a/ui/message_actions_list.go b/ui/message_actions_list.go deleted file mode 100644 index 1c7dee0..0000000 --- a/ui/message_actions_list.go +++ /dev/null @@ -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) -} diff --git a/ui/message_input.go b/ui/message_input.go deleted file mode 100644 index 42fea4a..0000000 --- a/ui/message_input.go +++ /dev/null @@ -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 -} diff --git a/ui/messages_panel.go b/ui/messages_panel.go deleted file mode 100644 index 483b63b..0000000 --- a/ui/messages_panel.go +++ /dev/null @@ -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 -} diff --git a/ui/messages_view.go b/ui/messages_view.go new file mode 100644 index 0000000..6b10379 --- /dev/null +++ b/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 +}