From f945fbf4f8972b315d3af7693bd39dd66c0e89db Mon Sep 17 00:00:00 2001 From: ayntgl <90600421+ayntgl@users.noreply.github.com> Date: Sun, 19 Sep 2021 19:56:51 +0400 Subject: [PATCH] Switch to a flat project structure (#48) --- util/config.go => config.go | 13 +- discord.go | 144 +++++++++++ discordo.go | 455 --------------------------------- main.go | 140 ++++++++++ util/discord.go => renderer.go | 107 ++------ ui.go | 363 ++++++++++++++++++++++++++ ui/guilds.go | 119 --------- ui/messages.go | 40 --- ui/misc.go | 40 --- 9 files changed, 676 insertions(+), 745 deletions(-) rename util/config.go => config.go (78%) create mode 100644 discord.go delete mode 100644 discordo.go create mode 100644 main.go rename util/discord.go => renderer.go (54%) create mode 100644 ui.go delete mode 100644 ui/guilds.go delete mode 100644 ui/messages.go delete mode 100644 ui/misc.go diff --git a/util/config.go b/config.go similarity index 78% rename from util/config.go rename to config.go index ad3713b..12be8b1 100644 --- a/util/config.go +++ b/config.go @@ -1,4 +1,4 @@ -package util +package main import ( "encoding/json" @@ -7,9 +7,7 @@ import ( "github.com/rivo/tview" ) -// Config consists of fields, such as theme, mouse, so on, that may be -// customized by the user. -type Config struct { +type config struct { Token string `json:"token"` Mouse bool `json:"mouse"` Notifications bool `json:"notifications"` @@ -18,8 +16,7 @@ type Config struct { Theme tview.Theme `json:"theme"` } -// LoadConfig creates (if the configuration file does not exist) and reads the configuration file and returns a new config. -func LoadConfig() *Config { +func loadConfig() *config { u, err := os.UserHomeDir() if err != nil { panic(err) @@ -32,7 +29,7 @@ func LoadConfig() *Config { panic(err) } - c := Config{ + c := config{ Mouse: true, Notifications: true, UserAgent: "" + @@ -60,7 +57,7 @@ func LoadConfig() *Config { panic(err) } - var c Config + var c config if err = json.Unmarshal(d, &c); err != nil { panic(err) } diff --git a/discord.go b/discord.go new file mode 100644 index 0000000..83e789b --- /dev/null +++ b/discord.go @@ -0,0 +1,144 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/bwmarrin/discordgo" + "github.com/gen2brain/beeep" + "github.com/rivo/tview" +) + +func newSession() *discordgo.Session { + s, err := discordgo.New() + if err != nil { + panic(err) + } + + s.UserAgent = conf.UserAgent + s.Identify.Compress = false + s.Identify.Intents = 0 + s.Identify.LargeThreshold = 0 + s.Identify.Properties.Device = "" + s.Identify.Properties.Browser = "Chrome" + s.Identify.Properties.OS = "Linux" + + s.AddHandlerOnce(onSessionReady) + s.AddHandler(onSessionMessageCreate) + + return s +} + +func onSessionReady(_ *discordgo.Session, r *discordgo.Ready) { + sort.Slice(r.Guilds, func(a, b int) bool { + found := false + for _, gID := range r.Settings.GuildPositions { + if found { + if gID == r.Guilds[b].ID { + return true + } + } else { + if gID == r.Guilds[a].ID { + found = true + } + } + } + + return false + }) + + n := guildsTreeView.GetRoot() + for _, g := range r.Guilds { + gn := tview.NewTreeNode(g.Name). + SetReference(g.ID) + n.AddChild(gn) + } + + guildsTreeView.SetCurrentNode(n) +} + +func onSessionMessageCreate(_ *discordgo.Session, m *discordgo.MessageCreate) { + if selectedChannel == nil { + selectedChannel = &discordgo.Channel{ID: ""} + } + + if selectedChannel.ID != m.ChannelID { + if conf.Notifications { + for _, u := range m.Mentions { + if u.ID == session.State.User.ID { + g, err := session.State.Guild(m.GuildID) + if err != nil { + return + } + c, err := session.State.Channel(m.ChannelID) + if err != nil { + return + } + + go beeep.Alert(fmt.Sprintf("%s (#%s)", g.Name, c.Name), m.ContentWithMentionsReplaced(), "") + return + } + } + } + + return + } + + selectedChannel.Messages = append(selectedChannel.Messages, m.Message) + renderMessage(m.Message) +} + +type loginResponse struct { + MFA bool `json:"mfa"` + SMS bool `json:"sms"` + Ticket string `json:"ticket"` + Token string `json:"token"` +} + +func login( + s *discordgo.Session, + email, password string, +) (*loginResponse, error) { + data := struct { + Email string `json:"email"` + Password string `json:"password"` + }{email, password} + resp, err := s.RequestWithBucketID( + "POST", + discordgo.EndpointLogin, + data, + discordgo.EndpointLogin, + ) + if err != nil { + return nil, err + } + + var lr loginResponse + err = json.Unmarshal(resp, &lr) + if err != nil { + return nil, err + } + + return &lr, nil +} + +func totp(s *discordgo.Session, code, ticket string) (*loginResponse, error) { + data := struct { + Code string `json:"code"` + Ticket string `json:"ticket"` + }{code, ticket} + e := discordgo.EndpointAuth + "mfa/totp" + resp, err := s.RequestWithBucketID("POST", e, data, e) + if err != nil { + return nil, err + } + + var lr loginResponse + err = json.Unmarshal(resp, &lr) + if err != nil { + return nil, err + } + + return &lr, nil +} diff --git a/discordo.go b/discordo.go deleted file mode 100644 index f323097..0000000 --- a/discordo.go +++ /dev/null @@ -1,455 +0,0 @@ -package main - -import ( - "fmt" - "sort" - "strings" - - "github.com/atotto/clipboard" - "github.com/bwmarrin/discordgo" - "github.com/gdamore/tcell/v2" - "github.com/gen2brain/beeep" - "github.com/rigormorrtiss/discordo/ui" - "github.com/rigormorrtiss/discordo/util" - "github.com/rivo/tview" - "github.com/zalando/go-keyring" -) - -var ( - app *tview.Application - loginWidget *tview.Form - guildsWidget *tview.TreeView - messagesWidget *tview.TextView - inputWidget *tview.InputField - mainFlex *tview.Flex - - config *util.Config - session *discordgo.Session - selectedChannel *discordgo.Channel - selectedMessage *discordgo.Message -) - -func main() { - config = util.LoadConfig() - tview.Styles = config.Theme - - app = tview.NewApplication() - app.EnableMouse(config.Mouse) - app.SetInputCapture(onAppInputCapture) - - guildsWidget = ui.NewGuildsWidget() - guildsWidget.SetSelectedFunc(onGuildsWidgetSelected) - - messagesWidget = ui.NewMessagesWidget(app) - messagesWidget.SetInputCapture(onMessagesWidgetInputCapture) - - inputWidget = ui.NewInputWidget() - inputWidget.SetInputCapture(onInputWidgetInputCapture) - - mainFlex = ui.NewMainFlex( - guildsWidget, - messagesWidget, - inputWidget, - ) - - token := config.Token - if t, _ := keyring.Get("discordo", "token"); t != "" { - token = t - } - - if token != "" { - app. - SetRoot(mainFlex, true). - SetFocus(guildsWidget) - - session = newSession() - session.Token = token - session.Identify.Token = token - if err := session.Open(); err != nil { - panic(err) - } - } else { - loginWidget = ui.NewLoginWidget(onLoginFormLoginButtonSelected, false) - app.SetRoot(loginWidget, true) - } - - if err := app.Run(); err != nil { - panic(err) - } -} - -func onAppInputCapture(e *tcell.EventKey) *tcell.EventKey { - if e.Modifiers() == tcell.ModAlt { - switch e.Rune() { - case '1': - app.SetFocus(guildsWidget) - case '2': - app.SetFocus(messagesWidget) - case '3': - app.SetFocus(inputWidget) - } - } - - return e -} - -func findByMessageID(ms []*discordgo.Message, mID string) (int, *discordgo.Message) { - for i, m := range ms { - if mID == m.ID { - return i, m - } - } - - return -1, nil -} - -func onMessagesWidgetInputCapture(e *tcell.EventKey) *tcell.EventKey { - if selectedChannel == nil { - return nil - } - - switch { - case e.Key() == tcell.KeyUp || e.Rune() == 'k': // Up - ms := selectedChannel.Messages - if len(ms) == 0 { - return nil - } - - hs := messagesWidget.GetHighlights() - if len(hs) == 0 { - messagesWidget. - Highlight(ms[len(ms)-1].ID). - ScrollToHighlight() - } else { - idx, _ := findByMessageID(ms, hs[0]) - if idx == -1 || idx == 0 { - return nil - } - - messagesWidget. - Highlight(ms[idx-1].ID). - ScrollToHighlight() - } - - return nil - case e.Key() == tcell.KeyDown || e.Rune() == 'j': // Down - ms := selectedChannel.Messages - if len(ms) == 0 { - return nil - } - - hs := messagesWidget.GetHighlights() - if len(hs) == 0 { - messagesWidget. - Highlight(ms[len(ms)-1].ID). - ScrollToHighlight() - } else { - idx, _ := findByMessageID(ms, hs[0]) - if idx == -1 || idx == len(ms)-1 { - return nil - } - - messagesWidget. - Highlight(ms[idx+1].ID). - ScrollToHighlight() - } - - return nil - case e.Key() == tcell.KeyHome || e.Rune() == 'g': // Top - ms := selectedChannel.Messages - if len(ms) == 0 { - return nil - } - - messagesWidget. - Highlight(ms[0].ID). - ScrollToHighlight() - case e.Key() == tcell.KeyEnd || e.Rune() == 'G': // Bottom - ms := selectedChannel.Messages - if len(ms) == 0 { - return nil - } - - messagesWidget. - Highlight(ms[len(ms)-1].ID). - ScrollToHighlight() - case e.Rune() == 'r': // Inline reply - ms := selectedChannel.Messages - if len(ms) == 0 { - return nil - } - - hs := messagesWidget.GetHighlights() - if len(hs) == 0 { - return nil - } - - _, selectedMessage = findByMessageID(ms, hs[0]) - inputWidget.SetTitle( - "Replying to " + selectedMessage.Author.Username, - ) - app.SetFocus(inputWidget) - } - - return e -} - -func onInputWidgetInputCapture(e *tcell.EventKey) *tcell.EventKey { - // If the "Alt" modifier key is pressed, do not handle the event. - if e.Modifiers() == tcell.ModAlt { - return nil - } - - switch e.Key() { - case tcell.KeyEnter: - if selectedChannel == nil { - return nil - } - - t := strings.TrimSpace(inputWidget.GetText()) - if t == "" { - return nil - } - - if selectedMessage != nil { - inputWidget.SetTitle("") - go session.ChannelMessageSendReply( - selectedMessage.ChannelID, - t, - selectedMessage.Reference(), - ) - - selectedMessage = nil - } else { - go session.ChannelMessageSend(selectedChannel.ID, t) - } - - inputWidget.SetText("") - case tcell.KeyCtrlV: - text, _ := clipboard.ReadAll() - text = inputWidget.GetText() + text - inputWidget.SetText(text) - case tcell.KeyEscape: - inputWidget.SetTitle("") - selectedMessage = nil - } - - return e -} - -func newSession() *discordgo.Session { - s, err := discordgo.New() - if err != nil { - panic(err) - } - - s.UserAgent = config.UserAgent - s.Identify.Compress = false - s.Identify.Intents = 0 - s.Identify.LargeThreshold = 0 - s.Identify.Properties.Device = "" - s.Identify.Properties.Browser = "Chrome" - s.Identify.Properties.OS = "Linux" - - s.AddHandlerOnce(onSessionReady) - s.AddHandler(onSessionMessageCreate) - - return s -} - -func onSessionReady(_ *discordgo.Session, r *discordgo.Ready) { - sort.Slice(r.Guilds, func(a, b int) bool { - found := false - for _, gID := range r.Settings.GuildPositions { - if found { - if gID == r.Guilds[b].ID { - return true - } - } else { - if gID == r.Guilds[a].ID { - found = true - } - } - } - - return false - }) - - n := guildsWidget.GetRoot() - for _, g := range r.Guilds { - gn := tview.NewTreeNode(g.Name). - SetReference(g.ID) - n.AddChild(gn) - } - - guildsWidget.SetCurrentNode(n) -} - -func onSessionMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { - if selectedChannel == nil { - selectedChannel = &discordgo.Channel{ID: ""} - } - - if selectedChannel.ID != m.ChannelID { - if config.Notifications { - for _, u := range m.Mentions { - if u.ID == s.State.User.ID { - g, err := s.State.Guild(m.GuildID) - if err != nil { - return - } - c, err := s.State.Channel(m.ChannelID) - if err != nil { - return - } - - go beeep.Alert(fmt.Sprintf("%s (#%s)", g.Name, c.Name), m.ContentWithMentionsReplaced(), "") - return - } - } - } - - return - } - - selectedChannel.Messages = append(selectedChannel.Messages, m.Message) - util.WriteMessage( - messagesWidget, - m.Message, - session.State.Ready.User.ID, - ) -} - -func onGuildsWidgetSelected(n *tview.TreeNode) { - selectedChannel = nil - selectedMessage = nil - messagesWidget. - Clear(). - SetTitle("") - // Unhighlight the already-highlighted regions. - messagesWidget.Highlight() - - switch n.GetLevel() { - case 1: - if len(n.GetChildren()) != 0 { - n.SetExpanded(!n.IsExpanded()) - return - } - - n.ClearChildren() - - gID := n.GetReference().(string) - g, err := session.State.Guild(gID) - if err != nil { - return - } - - cs := g.Channels - sort.Slice(cs, func(i, j int) bool { - return cs[i].Position < cs[j].Position - }) - - // Top-level channels - ui.CreateTopLevelChannelsTreeNodes(session.State, n, cs) - // Category channels - ui.CreateCategoryChannelsTreeNodes(session.State, n, cs) - // Second-level channels - ui.CreateSecondLevelChannelsTreeNodes(session.State, guildsWidget, cs) - default: - cID := n.GetReference().(string) - c, err := session.State.Channel(cID) - if err != nil { - return - } - - if c.Type == discordgo.ChannelTypeGuildCategory { - n.SetExpanded(!n.IsExpanded()) - } else if c.Type == discordgo.ChannelTypeGuildNews || c.Type == discordgo.ChannelTypeGuildText { - selectedChannel = c - app.SetFocus(inputWidget) - - title := "#" + c.Name - if c.Topic != "" { - title += " - " + c.Topic - } - messagesWidget. - Clear(). - SetTitle(title) - - go writeMessages(c.ID) - } - } -} - -func writeMessages(cID string) { - ms, err := session.ChannelMessages(cID, config.GetMessagesLimit, "", "", "") - if err != nil { - return - } - - for i := len(ms) - 1; i >= 0; i-- { - selectedChannel.Messages = append(selectedChannel.Messages, ms[i]) - - util.WriteMessage( - messagesWidget, - ms[i], - session.State.Ready.User.ID, - ) - } -} - -func onLoginFormLoginButtonSelected() { - email := loginWidget.GetFormItem(0).(*tview.InputField).GetText() - password := loginWidget.GetFormItem(1).(*tview.InputField).GetText() - if email == "" || password == "" { - return - } - - session = newSession() - // Login using the email and password - lr, err := util.Login(session, email, password) - if err != nil { - panic(err) - } - - if lr.Token != "" && !lr.MFA { - app. - SetRoot(mainFlex, true). - SetFocus(guildsWidget) - - session.Token = lr.Token - session.Identify.Token = lr.Token - if err = session.Open(); err != nil { - panic(err) - } - - go keyring.Set("discordo", "token", lr.Token) - } else if lr.MFA { - // The account has MFA enabled, reattempt login with code and ticket. - loginWidget = ui.NewLoginWidget(func() { - code := loginWidget.GetFormItem(0).(*tview.InputField).GetText() - if code == "" { - return - } - - lr, err = util.TOTP(session, code, lr.Ticket) - if err != nil { - panic(err) - } - - app. - SetRoot(mainFlex, true). - SetFocus(guildsWidget) - - session.Token = lr.Token - session.Identify.Token = lr.Token - if err = session.Open(); err != nil { - panic(err) - } - - go keyring.Set("discordo", "token", lr.Token) - }, true) - - app.SetRoot(loginWidget, true) - } -} diff --git a/main.go b/main.go new file mode 100644 index 0000000..fa713f9 --- /dev/null +++ b/main.go @@ -0,0 +1,140 @@ +package main + +import ( + "github.com/bwmarrin/discordgo" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/zalando/go-keyring" +) + +var ( + app *tview.Application + loginForm *tview.Form + guildsTreeView *tview.TreeView + messagesTextView *tview.TextView + messageInputField *tview.InputField + mainFlex *tview.Flex + + conf *config + session *discordgo.Session + selectedChannel *discordgo.Channel + selectedMessage *discordgo.Message +) + +func main() { + conf = loadConfig() + tview.Styles = conf.Theme + + app = tview.NewApplication() + app. + EnableMouse(conf.Mouse). + SetInputCapture(onAppInputCapture) + + guildsTreeView = newGuildsTreeView() + messagesTextView = newMessagesTextView() + messageInputField = newMessageInputField() + + rightFlex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(messagesTextView, 0, 1, false). + AddItem(messageInputField, 3, 1, false) + mainFlex = tview.NewFlex(). + AddItem(guildsTreeView, 0, 1, false). + AddItem(rightFlex, 0, 4, false) + + token := conf.Token + if t, _ := keyring.Get("discordo", "token"); t != "" { + token = t + } + + if token != "" { + app. + SetRoot(mainFlex, true). + SetFocus(guildsTreeView) + + session = newSession() + session.Token = token + session.Identify.Token = token + if err := session.Open(); err != nil { + panic(err) + } + } else { + loginForm = newLoginForm(onLoginFormLoginButtonSelected, false) + app.SetRoot(loginForm, true) + } + + if err := app.Run(); err != nil { + panic(err) + } +} + +func onAppInputCapture(e *tcell.EventKey) *tcell.EventKey { + if e.Modifiers() == tcell.ModAlt { + switch e.Rune() { + case '1': + app.SetFocus(guildsTreeView) + case '2': + app.SetFocus(messagesTextView) + case '3': + app.SetFocus(messageInputField) + } + } + + return e +} + +func onLoginFormLoginButtonSelected() { + email := loginForm.GetFormItem(0).(*tview.InputField).GetText() + password := loginForm.GetFormItem(1).(*tview.InputField).GetText() + if email == "" || password == "" { + return + } + + session = newSession() + // Login using the email and password + lr, err := login(session, email, password) + if err != nil { + panic(err) + } + + if lr.Token != "" && !lr.MFA { + app. + SetRoot(mainFlex, true). + SetFocus(guildsTreeView) + + session.Token = lr.Token + session.Identify.Token = lr.Token + if err = session.Open(); err != nil { + panic(err) + } + + go keyring.Set("discordo", "token", lr.Token) + } else if lr.MFA { + // The account has MFA enabled, reattempt login with code and ticket. + loginForm = newLoginForm(func() { + code := loginForm.GetFormItem(0).(*tview.InputField).GetText() + if code == "" { + return + } + + lr, err = totp(session, code, lr.Ticket) + if err != nil { + panic(err) + } + + app. + SetRoot(mainFlex, true). + SetFocus(guildsTreeView) + + session.Token = lr.Token + session.Identify.Token = lr.Token + if err = session.Open(); err != nil { + panic(err) + } + + go keyring.Set("discordo", "token", lr.Token) + }, true) + + app.SetRoot(loginForm, true) + } +} diff --git a/util/discord.go b/renderer.go similarity index 54% rename from util/discord.go rename to renderer.go index 0eea8c7..9ff3cf1 100644 --- a/util/discord.go +++ b/renderer.go @@ -1,16 +1,25 @@ -package util +package main import ( - "encoding/json" "fmt" "strings" "github.com/bwmarrin/discordgo" - "github.com/rivo/tview" ) -// WriteMessage parses, renders, and writes a message to the given TextView. -func WriteMessage(v *tview.TextView, m *discordgo.Message, clientID string) { +func renderMessages(cID string) { + ms, err := session.ChannelMessages(cID, conf.GetMessagesLimit, "", "", "") + if err != nil { + return + } + + for i := len(ms) - 1; i >= 0; i-- { + selectedChannel.Messages = append(selectedChannel.Messages, ms[i]) + renderMessage(ms[i]) + } +} + +func renderMessage(m *discordgo.Message) { var b strings.Builder switch m.Type { @@ -26,25 +35,21 @@ func WriteMessage(v *tview.TextView, m *discordgo.Message, clientID string) { if rm := m.ReferencedMessage; rm != nil { b.WriteString(" ╭ ") b.WriteString("[::d]") - parseAuthor(&b, rm.Author, clientID) + parseAuthor(&b, rm.Author) if rm.Content != "" { - rm.Content = parseMentions( - rm.Content, - rm.Mentions, - clientID, - ) + rm.Content = parseMentions(rm.Content, rm.Mentions) b.WriteString(rm.Content) } b.WriteString("[::-]\n") } // Render the author of the message. - parseAuthor(&b, m.Author, clientID) + parseAuthor(&b, m.Author) // If the message content is not empty, parse the message mentions // (users mentioned in the message) and render the message content. if m.Content != "" { - m.Content = parseMentions(m.Content, m.Mentions, clientID) + m.Content = parseMentions(m.Content, m.Mentions) b.WriteString(m.Content) } // If the edited timestamp of the message is not empty; it implies that @@ -68,24 +73,20 @@ func WriteMessage(v *tview.TextView, m *discordgo.Message, clientID string) { // therefore be used to mark the end of a region. b.WriteString("[\"\"]") - fmt.Fprintln(v, b.String()) + fmt.Fprintln(messagesTextView, b.String()) case discordgo.MessageTypeGuildMemberJoin: b.WriteString("[#5865F2]") b.WriteString(m.Author.Username) b.WriteString("[-] joined the server") - fmt.Fprintln(v, b.String()) + fmt.Fprintln(messagesTextView, b.String()) } } -func parseMentions( - content string, - mentions []*discordgo.User, - clientID string, -) string { +func parseMentions(content string, mentions []*discordgo.User) string { for _, mUser := range mentions { var color string - if mUser.ID == clientID { + if mUser.ID == session.State.User.ID { color = "[:#5865F2]" } else { color = "[#EB459E]" @@ -104,10 +105,8 @@ func parseMentions( return content } -func parseAuthor(b *strings.Builder, u *discordgo.User, clientID string) { - // If the message author is the client, modify the text color for - // distinction. - if u.ID == clientID { +func parseAuthor(b *strings.Builder, u *discordgo.User) { + if u.ID == session.State.User.ID { b.WriteString("[#57F287]") } else { b.WriteString("[#ED4245]") @@ -121,61 +120,3 @@ func parseAuthor(b *strings.Builder, u *discordgo.User, clientID string) { b.WriteString("[#EB459E]BOT[-] ") } } - -type loginResponse struct { - MFA bool `json:"mfa"` - SMS bool `json:"sms"` - Ticket string `json:"ticket"` - Token string `json:"token"` -} - -// Login creates a new request to the `/login` endpoint for essential login -// information. -func Login( - s *discordgo.Session, - email, password string, -) (*loginResponse, error) { - data := struct { - Email string `json:"email"` - Password string `json:"password"` - }{email, password} - resp, err := s.RequestWithBucketID( - "POST", - discordgo.EndpointLogin, - data, - discordgo.EndpointLogin, - ) - if err != nil { - return nil, err - } - - var lr loginResponse - err = json.Unmarshal(resp, &lr) - if err != nil { - return nil, err - } - - return &lr, nil -} - -// TOTP creates a new request to `/mfa/totp` endpoint for time-based one-time -// passcode for essential login information -func TOTP(s *discordgo.Session, code, ticket string) (*loginResponse, error) { - data := struct { - Code string `json:"code"` - Ticket string `json:"ticket"` - }{code, ticket} - e := discordgo.EndpointAuth + "mfa/totp" - resp, err := s.RequestWithBucketID("POST", e, data, e) - if err != nil { - return nil, err - } - - var lr loginResponse - err = json.Unmarshal(resp, &lr) - if err != nil { - return nil, err - } - - return &lr, nil -} diff --git a/ui.go b/ui.go new file mode 100644 index 0000000..75b2d81 --- /dev/null +++ b/ui.go @@ -0,0 +1,363 @@ +package main + +import ( + "sort" + "strings" + + "github.com/atotto/clipboard" + "github.com/bwmarrin/discordgo" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func newGuildsTreeView() *tview.TreeView { + w := tview.NewTreeView() + w. + SetSelectedFunc(onGuildsTreeViewSelected). + SetTopLevel(1). + SetRoot(tview.NewTreeNode("")). + SetBorder(true). + SetBorderPadding(0, 0, 1, 0). + SetTitle("Guilds"). + SetTitleAlign(tview.AlignLeft) + + return w +} + +func onGuildsTreeViewSelected(n *tview.TreeNode) { + selectedChannel = nil + selectedMessage = nil + messagesTextView. + Clear(). + SetTitle("") + messageInputField.SetText("") + // Unhighlight the already-highlighted regions. + messagesTextView.Highlight() + + switch n.GetLevel() { + case 1: + if len(n.GetChildren()) != 0 { + n.SetExpanded(!n.IsExpanded()) + return + } + + n.ClearChildren() + + gID := n.GetReference().(string) + g, err := session.State.Guild(gID) + if err != nil { + return + } + + cs := g.Channels + sort.Slice(cs, func(i, j int) bool { + return cs[i].Position < cs[j].Position + }) + + // Top-level channels + createTopLevelChannelsTreeNodes(n, cs) + // Category channels + createCategoryChannelsTreeNodes(n, cs) + // Second-level channels + createSecondLevelChannelsTreeNodes(cs) + default: + cID := n.GetReference().(string) + c, err := session.State.Channel(cID) + if err != nil { + return + } + + if c.Type == discordgo.ChannelTypeGuildCategory { + n.SetExpanded(!n.IsExpanded()) + } else if c.Type == discordgo.ChannelTypeGuildNews || c.Type == discordgo.ChannelTypeGuildText { + selectedChannel = c + app.SetFocus(messageInputField) + + title := "#" + c.Name + if c.Topic != "" { + title += " - " + c.Topic + } + messagesTextView. + Clear(). + SetTitle(title) + + go renderMessages(c.ID) + } + } +} + +func newTextChannelTreeNode(c *discordgo.Channel) *tview.TreeNode { + n := tview.NewTreeNode("[::d]#" + c.Name + "[::-]"). + SetReference(c.ID) + + return n +} + +func createTopLevelChannelsTreeNodes( + n *tview.TreeNode, + cs []*discordgo.Channel, +) { + for _, c := range cs { + if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) && + (c.ParentID == "") { + if p, err := session.State.UserChannelPermissions(session.State.User.ID, c.ID); err != nil || p&discordgo.PermissionViewChannel != discordgo.PermissionViewChannel { + continue + } + + cn := newTextChannelTreeNode(c) + n.AddChild(cn) + continue + } + } +} + +func createCategoryChannelsTreeNodes( + n *tview.TreeNode, + cs []*discordgo.Channel, +) { +CategoryLoop: + for _, c := range cs { + if c.Type == discordgo.ChannelTypeGuildCategory { + if p, err := session.State.UserChannelPermissions(session.State.User.ID, c.ID); err != nil || p&discordgo.PermissionViewChannel != discordgo.PermissionViewChannel { + continue + } + + for _, child := range cs { + if child.ParentID == c.ID { + cn := tview.NewTreeNode(c.Name). + SetReference(c.ID) + n.AddChild(cn) + continue CategoryLoop + } + } + + cn := tview.NewTreeNode(c.Name). + SetReference(c.ID) + n.AddChild(cn) + } + } +} + +func createSecondLevelChannelsTreeNodes(cs []*discordgo.Channel) { + for _, c := range cs { + if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) && + (c.ParentID != "") { + if p, err := session.State.UserChannelPermissions(session.State.User.ID, c.ID); err != nil || p&discordgo.PermissionViewChannel != discordgo.PermissionViewChannel { + continue + } + + if pn := getTreeNodeByReference(c.ParentID); pn != nil { + cn := newTextChannelTreeNode(c) + pn.AddChild(cn) + } + } + } +} + +func getTreeNodeByReference(r interface{}) (mn *tview.TreeNode) { + guildsTreeView.GetRoot().Walk(func(n, _ *tview.TreeNode) bool { + if n.GetReference() == r { + mn = n + return false + } + + return true + }) + + return +} + +func newMessagesTextView() *tview.TextView { + w := tview.NewTextView() + w. + SetRegions(true). + SetDynamicColors(true). + SetWordWrap(true). + ScrollToEnd(). + SetChangedFunc(func() { + app.Draw() + }). + SetInputCapture(onMessagesTextViewInputCapture). + SetBorder(true). + SetBorderPadding(0, 0, 1, 0). + SetTitleAlign(tview.AlignLeft) + + return w +} + +func findByMessageID(ms []*discordgo.Message, mID string) (int, *discordgo.Message) { + for i, m := range ms { + if mID == m.ID { + return i, m + } + } + + return -1, nil +} + +func onMessagesTextViewInputCapture(e *tcell.EventKey) *tcell.EventKey { + if selectedChannel == nil { + return nil + } + + switch { + case e.Key() == tcell.KeyUp || e.Rune() == 'k': // Up + ms := selectedChannel.Messages + if len(ms) == 0 { + return nil + } + + hs := messagesTextView.GetHighlights() + if len(hs) == 0 { + messagesTextView. + Highlight(ms[len(ms)-1].ID). + ScrollToHighlight() + } else { + idx, _ := findByMessageID(ms, hs[0]) + if idx == -1 || idx == 0 { + return nil + } + + messagesTextView. + Highlight(ms[idx-1].ID). + ScrollToHighlight() + } + + return nil + case e.Key() == tcell.KeyDown || e.Rune() == 'j': // Down + ms := selectedChannel.Messages + if len(ms) == 0 { + return nil + } + + hs := messagesTextView.GetHighlights() + if len(hs) == 0 { + messagesTextView. + Highlight(ms[len(ms)-1].ID). + ScrollToHighlight() + } else { + idx, _ := findByMessageID(ms, hs[0]) + if idx == -1 || idx == len(ms)-1 { + return nil + } + + messagesTextView. + Highlight(ms[idx+1].ID). + ScrollToHighlight() + } + + return nil + case e.Key() == tcell.KeyHome || e.Rune() == 'g': // Top + ms := selectedChannel.Messages + if len(ms) == 0 { + return nil + } + + messagesTextView. + Highlight(ms[0].ID). + ScrollToHighlight() + case e.Key() == tcell.KeyEnd || e.Rune() == 'G': // Bottom + ms := selectedChannel.Messages + if len(ms) == 0 { + return nil + } + + messagesTextView. + Highlight(ms[len(ms)-1].ID). + ScrollToHighlight() + case e.Rune() == 'r': // Inline reply + ms := selectedChannel.Messages + if len(ms) == 0 { + return nil + } + + hs := messagesTextView.GetHighlights() + if len(hs) == 0 { + return nil + } + + _, selectedMessage = findByMessageID(ms, hs[0]) + messageInputField.SetTitle( + "Replying to " + selectedMessage.Author.Username, + ) + app.SetFocus(messageInputField) + } + + return e +} + +func newMessageInputField() *tview.InputField { + w := tview.NewInputField() + w. + SetPlaceholder("Message..."). + SetPlaceholderTextColor(tcell.ColorWhite). + SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor). + SetInputCapture(onMessageInputFieldInputCapture). + SetBorder(true). + SetBorderPadding(0, 0, 1, 0). + SetTitleAlign(tview.AlignLeft) + + return w +} + +func onMessageInputFieldInputCapture(e *tcell.EventKey) *tcell.EventKey { + // If the "Alt" modifier key is pressed, do not handle the event. + if e.Modifiers() == tcell.ModAlt { + return nil + } + + switch e.Key() { + case tcell.KeyEnter: + if selectedChannel == nil { + return nil + } + + t := strings.TrimSpace(messageInputField.GetText()) + if t == "" { + return nil + } + + if selectedMessage != nil { + messageInputField.SetTitle("") + go session.ChannelMessageSendReply( + selectedMessage.ChannelID, + t, + selectedMessage.Reference(), + ) + + selectedMessage = nil + } else { + go session.ChannelMessageSend(selectedChannel.ID, t) + } + + messageInputField.SetText("") + case tcell.KeyCtrlV: + text, _ := clipboard.ReadAll() + text = messageInputField.GetText() + text + messageInputField.SetText(text) + case tcell.KeyEscape: + messageInputField.SetTitle("") + selectedMessage = nil + } + + return e +} + +func newLoginForm(onLoginFormLoginButtonSelected func(), mfa bool) *tview.Form { + w := tview.NewForm() + w. + AddButton("Login", onLoginFormLoginButtonSelected). + SetButtonsAlign(tview.AlignCenter). + SetBorder(true). + SetBorderPadding(0, 0, 1, 0) + + if mfa { + w.AddPasswordField("Code", "", 0, 0, nil) + } else { + w. + AddInputField("Email", "", 0, nil, nil). + AddPasswordField("Password", "", 0, 0, nil) + } + + return w +} diff --git a/ui/guilds.go b/ui/guilds.go deleted file mode 100644 index a95d95c..0000000 --- a/ui/guilds.go +++ /dev/null @@ -1,119 +0,0 @@ -package ui - -import ( - "github.com/bwmarrin/discordgo" - "github.com/rivo/tview" -) - -// NewGuildsWidget creates and returns a new guilds widget. -func NewGuildsWidget() *tview.TreeView { - w := tview.NewTreeView() - w. - SetTopLevel(1). - SetRoot(tview.NewTreeNode("")). - SetBorder(true). - SetBorderPadding(0, 0, 1, 0). - SetTitle("Guilds"). - SetTitleAlign(tview.AlignLeft) - - return w -} - -// NewTextChannelTreeNode creates and returns a new text channel treenode. -func NewTextChannelTreeNode(c *discordgo.Channel) *tview.TreeNode { - n := tview.NewTreeNode("[::d]#" + c.Name + "[::-]"). - SetReference(c.ID) - - return n -} - -// GetTreeNodeByReference gets the TreeNode that has reference r from the given -// treeview. -func GetTreeNodeByReference( - r interface{}, - treeV *tview.TreeView, -) (mn *tview.TreeNode) { - treeV.GetRoot().Walk(func(n, _ *tview.TreeNode) bool { - if n.GetReference() == r { - mn = n - return false - } - - return true - }) - - return -} - -// CreateTopLevelChannelsTreeNodes creates TreeNodes for the top-level (orphan) -// channels. -func CreateTopLevelChannelsTreeNodes( - s *discordgo.State, - n *tview.TreeNode, - cs []*discordgo.Channel, -) { - for _, c := range cs { - if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) && - (c.ParentID == "") { - if p, err := s.UserChannelPermissions(s.User.ID, c.ID); err != nil || p&discordgo.PermissionViewChannel != discordgo.PermissionViewChannel { - continue - } - - cn := NewTextChannelTreeNode(c) - n.AddChild(cn) - continue - } - } -} - -// CreateCategoryChannelsTreeNodes creates TreeNodes for the category (parent) -// channels. -func CreateCategoryChannelsTreeNodes( - s *discordgo.State, - n *tview.TreeNode, - cs []*discordgo.Channel, -) { -CategoryLoop: - for _, c := range cs { - if c.Type == discordgo.ChannelTypeGuildCategory { - if p, err := s.UserChannelPermissions(s.User.ID, c.ID); err != nil || p&discordgo.PermissionViewChannel != discordgo.PermissionViewChannel { - continue - } - - for _, child := range cs { - if child.ParentID == c.ID { - cn := tview.NewTreeNode(c.Name). - SetReference(c.ID) - n.AddChild(cn) - continue CategoryLoop - } - } - - cn := tview.NewTreeNode(c.Name). - SetReference(c.ID) - n.AddChild(cn) - } - } -} - -// CreateSecondLevelChannelsTreeNodes creates TreeNodes for the second-level -// (category children) channels. -func CreateSecondLevelChannelsTreeNodes( - s *discordgo.State, - treeV *tview.TreeView, - cs []*discordgo.Channel, -) { - for _, c := range cs { - if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) && - (c.ParentID != "") { - if p, err := s.UserChannelPermissions(s.User.ID, c.ID); err != nil || p&discordgo.PermissionViewChannel != discordgo.PermissionViewChannel { - continue - } - - if pn := GetTreeNodeByReference(c.ParentID, treeV); pn != nil { - cn := NewTextChannelTreeNode(c) - pn.AddChild(cn) - } - } - } -} diff --git a/ui/messages.go b/ui/messages.go deleted file mode 100644 index 58d3489..0000000 --- a/ui/messages.go +++ /dev/null @@ -1,40 +0,0 @@ -package ui - -import ( - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -// NewMessagesWidget creates and returns a new messages widget. -func NewMessagesWidget( - app *tview.Application, -) *tview.TextView { - w := tview.NewTextView() - w. - SetRegions(true). - SetDynamicColors(true). - SetWordWrap(true). - ScrollToEnd(). - SetChangedFunc(func() { - app.Draw() - }). - SetBorder(true). - SetBorderPadding(0, 0, 1, 0). - SetTitleAlign(tview.AlignLeft) - - return w -} - -// NewInputWidget creates and returns a new input widget. -func NewInputWidget() *tview.InputField { - w := tview.NewInputField() - w. - SetPlaceholder("Message..."). - SetPlaceholderTextColor(tcell.ColorWhite). - SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor). - SetBorder(true). - SetBorderPadding(0, 0, 1, 0). - SetTitleAlign(tview.AlignLeft) - - return w -} diff --git a/ui/misc.go b/ui/misc.go deleted file mode 100644 index 849bbbb..0000000 --- a/ui/misc.go +++ /dev/null @@ -1,40 +0,0 @@ -package ui - -import "github.com/rivo/tview" - -// NewMainFlex creates and returns a new main flex. -func NewMainFlex( - treeV *tview.TreeView, - textV *tview.TextView, - i *tview.InputField, -) *tview.Flex { - rf := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(textV, 0, 1, false). - AddItem(i, 3, 1, false) - mf := tview.NewFlex(). - AddItem(treeV, 0, 1, false). - AddItem(rf, 0, 4, false) - - return mf -} - -// NewLoginWidget creates and returns a new login widget. -func NewLoginWidget(onLoginFormLoginButtonSelected func(), mfa bool) *tview.Form { - w := tview.NewForm() - w. - AddButton("Login", onLoginFormLoginButtonSelected). - SetButtonsAlign(tview.AlignCenter). - SetBorder(true). - SetBorderPadding(0, 0, 1, 0) - - if mfa { - w.AddPasswordField("Code", "", 0, 0, nil) - } else { - w. - AddInputField("Email", "", 0, nil, nil). - AddPasswordField("Password", "", 0, 0, nil) - } - - return w -}