diff --git a/main.go b/main.go index ea7415e..4deb130 100644 --- a/main.go +++ b/main.go @@ -1,70 +1,21 @@ package main import ( + "encoding/json" "os" "github.com/ayntgl/discordgo" "github.com/ayntgl/discordo/config" "github.com/ayntgl/discordo/ui" - "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/zalando/go-keyring" ) const service = "discordo" -var ( - app *ui.App - loginForm *tview.Form - channelsTreeView *tview.TreeView - messagesTextView *tview.TextView - messageInputField *tview.InputField - mainFlex *tview.Flex - - selectedChannel *discordgo.Channel - selectedMessage int = -1 -) - func main() { - app = ui.NewApp() - app. - EnableMouse(config.General.Mouse). - SetInputCapture(onAppInputCapture) - - app.ChannelsTreeView. - SetTopLevel(1). - SetRoot(tview.NewTreeNode("")). - SetSelectedFunc(onChannelsTreeSelected). - SetTitleAlign(tview.AlignLeft). - SetBorder(true). - SetBorderPadding(0, 0, 1, 0) - - app.MessagesTextView. - SetRegions(true). - SetDynamicColors(true). - SetWordWrap(true). - SetChangedFunc(func() { app.Draw() }). - SetTitleAlign(tview.AlignLeft). - SetInputCapture(onMessagesViewInputCapture). - SetBorder(true). - SetBorderPadding(0, 0, 1, 0) - - app.MessageInputField. - SetPlaceholder("Message..."). - SetPlaceholderTextColor(tcell.ColorWhite). - SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor). - SetInputCapture(onMessageInputFieldInputCapture). - SetTitleAlign(tview.AlignLeft). - SetBorder(true). - SetBorderPadding(0, 0, 1, 0) - - rightFlex := tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(messagesTextView, 0, 1, false). - AddItem(messageInputField, 3, 1, false) - mainFlex = tview.NewFlex(). - AddItem(channelsTreeView, 0, 1, false). - AddItem(rightFlex, 0, 4, false) + app := ui.NewApp() + app.EnableMouse(config.General.Mouse) token := os.Getenv("DISCORDO_TOKEN") if token == "" { @@ -72,19 +23,20 @@ func main() { } if token != "" { - app. - SetRoot(mainFlex, true). - SetFocus(channelsTreeView) - - app.Session.AddHandlerOnce(onSessionReady) - app.Session.AddHandler(onSessionMessageCreate) err := app.Connect(token) if err != nil { panic(err) } + + ui.DrawMainFlex(app) + app. + SetRoot(app.MainFlex, true). + SetFocus(app.GuildsList) } else { - loginForm = ui.NewLoginForm(onLoginFormLoginButtonSelected, false) - app.SetRoot(loginForm, true) + app.LoginForm = ui.NewLoginForm(func() { + onLoginFormLoginButtonSelected(app) + }, false) + app.SetRoot(app.LoginForm, true) } if err := app.Run(); err != nil { @@ -92,26 +44,24 @@ func main() { } } -func onLoginFormLoginButtonSelected() { - email := loginForm.GetFormItem(0).(*tview.InputField).GetText() - password := loginForm.GetFormItem(1).(*tview.InputField).GetText() +func onLoginFormLoginButtonSelected(app *ui.App) { + email := app.LoginForm.GetFormItem(0).(*tview.InputField).GetText() + password := app.LoginForm.GetFormItem(1).(*tview.InputField).GetText() if email == "" || password == "" { return } // Login using the email and password - lr, err := login(email, password) + lr, err := login(app.Session, email, password) if err != nil { panic(err) } if lr.Token != "" && !lr.MFA { app. - SetRoot(mainFlex, true). - SetFocus(channelsTreeView) + SetRoot(app.MainFlex, true). + SetFocus(app.GuildsList) - app.Session.AddHandlerOnce(onSessionReady) - app.Session.AddHandler(onSessionMessageCreate) err = app.Connect(lr.Token) if err != nil { panic(err) @@ -120,23 +70,21 @@ func onLoginFormLoginButtonSelected() { go keyring.Set(service, "token", lr.Token) } else if lr.MFA { // The account has MFA enabled, reattempt login with code and ticket. - loginForm = ui.NewLoginForm(func() { - code := loginForm.GetFormItem(0).(*tview.InputField).GetText() + app.LoginForm = ui.NewLoginForm(func() { + code := app.LoginForm.GetFormItem(0).(*tview.InputField).GetText() if code == "" { return } - lr, err = totp(code, lr.Ticket) + lr, err = totp(app.Session, code, lr.Ticket) if err != nil { panic(err) } app. - SetRoot(mainFlex, true). - SetFocus(channelsTreeView) + SetRoot(app.MainFlex, true). + SetFocus(app.GuildsList) - app.Session.AddHandlerOnce(onSessionReady) - app.Session.AddHandler(onSessionMessageCreate) err = app.Connect(lr.Token) if err != nil { panic(err) @@ -145,6 +93,57 @@ func onLoginFormLoginButtonSelected() { go keyring.Set(service, "token", lr.Token) }, true) - app.SetRoot(loginForm, true) + app.SetRoot(app.LoginForm, true) } } + +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 string, 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 string, 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 deleted file mode 100644 index d93a86c..0000000 --- a/ui.go +++ /dev/null @@ -1,335 +0,0 @@ -package main - -import ( - "sort" - "strings" - - "github.com/atotto/clipboard" - "github.com/ayntgl/discordgo" - "github.com/ayntgl/discordo/config" - "github.com/ayntgl/discordo/util" - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -func onAppInputCapture(e *tcell.EventKey) *tcell.EventKey { - if util.HasKeybinding(config.Keybindings.FocusChannelsTreeView, e.Name()) { - app.SetFocus(channelsTreeView) - return nil - } else if util.HasKeybinding(config.Keybindings.FocusMessagesView, e.Name()) { - app.SetFocus(messagesTextView) - return nil - } else if util.HasKeybinding(config.Keybindings.FocusMessageInputField, e.Name()) { - app.SetFocus(messageInputField) - return nil - } - - return e -} - -func onChannelsTreeSelected(n *tview.TreeNode) { - selectedChannel = nil - selectedMessage = 0 - messagesTextView. - Clear(). - SetTitle("") - messageInputField.SetText("") - // Unhighlight the already-highlighted regions. - messagesTextView.Highlight() - - id := n.GetReference() - switch n.GetLevel() { - case 1: // Guilds or Direct Messages - if len(n.GetChildren()) == 0 { - // If the reference of the selected `*TreeNode` is `nil`, it is the direct messages `*TreeNode`. - if id == nil { - cs := app.Session.State.PrivateChannels - sort.Slice(cs, func(i, j int) bool { - return cs[i].LastMessageID > cs[j].LastMessageID - }) - - for _, c := range cs { - tag := "[::d]" - if util.ChannelIsUnread(app.Session.State, c) { - tag = "[::b]" - } - - cn := tview.NewTreeNode(tag + util.ChannelToString(c) + "[::-]"). - SetReference(c.ID). - Collapse() - n.AddChild(cn) - } - } else { - g, err := app.Session.State.Guild(id.(string)) - if err != nil { - return - } - - sort.Slice(g.Channels, func(i, j int) bool { - return g.Channels[i].Position < g.Channels[j].Position - }) - - // Top-level channels - createTopLevelChannelsNodes(channelsTreeView, app.Session.State, n, g.Channels) - // Category channels - createCategoryChannelsNodes(channelsTreeView, app.Session.State, n, g.Channels) - // Second-level channels - createSecondLevelChannelsNodes(channelsTreeView, app.Session.State, g.Channels) - } - } - - n.SetExpanded(!n.IsExpanded()) - default: // Channels - c, err := app.Session.State.Channel(id.(string)) - if err != nil { - return - } - - selectedChannel = c - app.SetFocus(messageInputField) - - switch c.Type { - case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews: - title := util.ChannelToString(c) - if c.Topic != "" { - title += " - " + c.Topic - } - - messagesTextView.SetTitle(title) - case discordgo.ChannelTypeDM, discordgo.ChannelTypeGroupDM: - messagesTextView.SetTitle(util.ChannelToString(c)) - } - - if strings.HasPrefix(n.GetText(), "[::b]") { - n.SetText("[::d]" + util.ChannelToString(c) + "[::-]") - } - - go func() { - ms, err := app.Session.ChannelMessages(c.ID, config.General.FetchMessagesLimit, "", "", "") - if err != nil { - return - } - - for i := len(ms) - 1; i >= 0; i-- { - selectedChannel.Messages = append(selectedChannel.Messages, ms[i]) - messagesTextView.Write(buildMessage(ms[i])) - } - // Scroll to the end of the text after the messages have been written to the TextView. - messagesTextView.ScrollToEnd() - - if len(ms) != 0 && util.ChannelIsUnread(app.Session.State, c) { - app.Session.ChannelMessageAck(c.ID, c.LastMessageID, "") - } - }() - } -} - -// createTopLevelChannelsNodes builds and creates `*tview.TreeNode`s for top-level (channels that have an empty parent ID and of type GUILD_TEXT, GUILD_NEWS) channels. If the client user does not have the VIEW_CHANNEL permission for a channel, the channel is excluded from the parent. -func createTopLevelChannelsNodes(treeView *tview.TreeView, 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 !util.HasPermission(s, c.ID, discordgo.PermissionViewChannel) { - continue - } - - n.AddChild(util.CreateChannelNode(s, c)) - continue - } - } -} - -// createCategoryChannelsNodes builds and creates `*tview.TreeNode`s for category (type: GUILD_CATEGORY) channels. If the client user does not have the VIEW_CHANNEL permission for a channel, the channel is excluded from the parent. -func createCategoryChannelsNodes(treeView *tview.TreeView, s *discordgo.State, n *tview.TreeNode, cs []*discordgo.Channel) { -CategoryLoop: - for _, c := range cs { - if c.Type == discordgo.ChannelTypeGuildCategory { - if !util.HasPermission(s, c.ID, discordgo.PermissionViewChannel) { - continue - } - - for _, child := range cs { - if child.ParentID == c.ID { - n.AddChild(util.CreateChannelNode(s, c)) - continue CategoryLoop - } - } - - n.AddChild(util.CreateChannelNode(s, c)) - } - } -} - -// createSecondLevelChannelsNodes builds and creates `*tview.TreeNode`s for second-level (channels that have a non-empty parent ID and of type GUILD_TEXT, GUILD_NEWS) channels. If the client user does not have the VIEW_CHANNEL permission for a channel, the channel is excluded from the parent. -func createSecondLevelChannelsNodes(treeView *tview.TreeView, s *discordgo.State, cs []*discordgo.Channel) { - for _, c := range cs { - if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) && - (c.ParentID != "") { - if !util.HasPermission(s, c.ID, discordgo.PermissionViewChannel) { - continue - } - - pn := util.GetNodeByReference(treeView, c.ParentID) - if pn != nil { - pn.AddChild(util.CreateChannelNode(s, c)) - } - } - } -} - -func onMessagesViewInputCapture(e *tcell.EventKey) *tcell.EventKey { - if selectedChannel == nil { - return nil - } - - ms := selectedChannel.Messages - if len(ms) == 0 { - return nil - } - - if util.HasKeybinding(config.Keybindings.SelectPreviousMessage, e.Name()) { - if len(messagesTextView.GetHighlights()) == 0 { - selectedMessage = len(ms) - 1 - } else { - selectedMessage-- - if selectedMessage < 0 { - selectedMessage = 0 - } - } - - messagesTextView. - Highlight(ms[selectedMessage].ID). - ScrollToHighlight() - return nil - } else if util.HasKeybinding(config.Keybindings.SelectNextMessage, e.Name()) { - if len(messagesTextView.GetHighlights()) == 0 { - selectedMessage = len(ms) - 1 - } else { - selectedMessage++ - if selectedMessage >= len(ms) { - selectedMessage = len(ms) - 1 - } - } - - messagesTextView. - Highlight(ms[selectedMessage].ID). - ScrollToHighlight() - return nil - } else if util.HasKeybinding(config.Keybindings.SelectFirstMessage, e.Name()) { - selectedMessage = 0 - messagesTextView. - Highlight(ms[selectedMessage].ID). - ScrollToHighlight() - return nil - } else if util.HasKeybinding(config.Keybindings.SelectLastMessage, e.Name()) { - selectedMessage = len(ms) - 1 - messagesTextView. - Highlight(ms[selectedMessage].ID). - ScrollToHighlight() - return nil - } else if util.HasKeybinding(config.Keybindings.SelectMessageReference, e.Name()) { - hs := messagesTextView.GetHighlights() - if len(hs) == 0 { - return nil - } - - _, m := util.FindMessageByID(selectedChannel.Messages, hs[0]) - if m.ReferencedMessage != nil { - selectedMessage, _ = util.FindMessageByID(selectedChannel.Messages, m.ReferencedMessage.ID) - messagesTextView. - Highlight(m.ReferencedMessage.ID). - ScrollToHighlight() - } - - return nil - } else if util.HasKeybinding(config.Keybindings.ReplySelectedMessage, e.Name()) { - hs := messagesTextView.GetHighlights() - if len(hs) == 0 { - return nil - } - - _, m := util.FindMessageByID(selectedChannel.Messages, hs[0]) - messageInputField.SetTitle("Replying to " + m.Author.String()) - app.SetFocus(messageInputField) - return nil - } else if util.HasKeybinding(config.Keybindings.MentionReplySelectedMessage, e.Name()) { - hs := messagesTextView.GetHighlights() - if len(hs) == 0 { - return nil - } - - _, m := util.FindMessageByID(selectedChannel.Messages, hs[0]) - messageInputField.SetTitle("[@] Replying to " + m.Author.String()) - app.SetFocus(messageInputField) - return nil - } else if util.HasKeybinding(config.Keybindings.CopySelectedMessage, e.Name()) { - hs := messagesTextView.GetHighlights() - if len(hs) == 0 { - return nil - } - - _, m := util.FindMessageByID(selectedChannel.Messages, hs[0]) - err := clipboard.WriteAll(m.Content) - if err != nil { - return nil - } - - return nil - } - - return e -} - -func onMessageInputFieldInputCapture(e *tcell.EventKey) *tcell.EventKey { - switch e.Key() { - case tcell.KeyEnter: - if selectedChannel == nil { - return nil - } - - t := strings.TrimSpace(messageInputField.GetText()) - if t == "" { - return nil - } - - if len(messagesTextView.GetHighlights()) != 0 { - _, m := util.FindMessageByID(selectedChannel.Messages, messagesTextView.GetHighlights()[0]) - d := &discordgo.MessageSend{ - Content: t, - Reference: m.Reference(), - AllowedMentions: &discordgo.MessageAllowedMentions{RepliedUser: false}, - } - if strings.HasPrefix(messageInputField.GetTitle(), "[@]") { - d.AllowedMentions.RepliedUser = true - } else { - d.AllowedMentions.RepliedUser = false - } - - go app.Session.ChannelMessageSendComplex(m.ChannelID, d) - - selectedMessage = -1 - messagesTextView.Highlight() - - messageInputField.SetTitle("") - } else { - go app.Session.ChannelMessageSend(selectedChannel.ID, t) - } - - messageInputField.SetText("") - return nil - case tcell.KeyCtrlV: - text, _ := clipboard.ReadAll() - text = messageInputField.GetText() + text - messageInputField.SetText(text) - return nil - case tcell.KeyEscape: - messageInputField.SetText("") - messageInputField.SetTitle("") - - selectedMessage = -1 - messagesTextView.Highlight() - return nil - } - - return e -} diff --git a/ui/app.go b/ui/app.go index 3741ba6..7788f15 100644 --- a/ui/app.go +++ b/ui/app.go @@ -1,19 +1,28 @@ package ui import ( + "fmt" + "sort" + "github.com/ayntgl/discordgo" "github.com/ayntgl/discordo/config" + "github.com/gen2brain/beeep" "github.com/rivo/tview" ) type App struct { *tview.Application + LoginForm *tview.Form + MainFlex *tview.Flex + GuildsList *tview.List ChannelsTreeView *tview.TreeView MessagesTextView *tview.TextView MessageInputField *tview.InputField - Session *discordgo.Session + Session *discordgo.Session + SelectedChannel *discordgo.Channel + SelectedMessage int } func NewApp() *App { @@ -21,11 +30,15 @@ func NewApp() *App { return &App{ Application: tview.NewApplication(), + LoginForm: tview.NewForm(), + MainFlex: tview.NewFlex(), + GuildsList: tview.NewList(), ChannelsTreeView: tview.NewTreeView(), MessagesTextView: tview.NewTextView(), MessageInputField: tview.NewInputField(), - Session: s, + Session: s, + SelectedMessage: -1, } } @@ -33,16 +46,68 @@ func (app *App) Connect(token string) error { app.Session.Token = token app.Session.UserAgent = config.General.UserAgent - app.Session.Identify.Token = token - app.Session.Identify.Compress = false - app.Session.Identify.Intents = 0 - app.Session.Identify.LargeThreshold = 0 - app.Session.Identify.Properties.Device = "" - app.Session.Identify.Properties.Browser = "Firefox" - app.Session.Identify.Properties.OS = "Linux" - - // app.Session.AddHandlerOnce(onSessionReady) - // app.Session.AddHandler(onSessionMessageCreate) + app.Session.Identify = discordgo.Identify{ + Token: token, + Compress: false, + LargeThreshold: 0, + Intents: 0, + Properties: discordgo.IdentifyProperties{ + OS: "Linux", + Browser: "Firefox", + Device: "", + }, + } + app.Session.AddHandlerOnce(app.onSessionReady) + app.Session.AddHandler(app.onSessionMessageCreate) return app.Session.Open() } + +func (app *App) onSessionReady(_ *discordgo.Session, r *discordgo.Ready) { + sort.Slice(r.Guilds, func(a, b int) bool { + found := false + for _, guildID := range r.Settings.GuildPositions { + if found && guildID == r.Guilds[b].ID { + return true + } + if !found && guildID == r.Guilds[a].ID { + found = true + } + } + + return false + }) + + for _, g := range r.Guilds { + app.GuildsList.AddItem(g.Name, "", 0, nil) + } +} + +func (app *App) onSessionMessageCreate(_ *discordgo.Session, m *discordgo.MessageCreate) { + if app.SelectedChannel == nil || app.SelectedChannel.ID != m.ChannelID { + if config.General.Notifications { + for _, u := range m.Mentions { + if u.ID == app.Session.State.User.ID { + g, err := app.Session.State.Guild(m.GuildID) + if err != nil { + return + } + + c, err := app.Session.State.Channel(m.ChannelID) + if err != nil { + return + } + + go beeep.Alert(fmt.Sprintf("%s (#%s)", g.Name, c.Name), m.ContentWithMentionsReplaced(), "") + } + } + } + } else { + app.SelectedChannel.Messages = append(app.SelectedChannel.Messages, m.Message) + app.MessagesTextView.Write(buildMessage(app, m.Message)) + + if len(app.MessagesTextView.GetHighlights()) == 0 { + app.MessagesTextView.ScrollToEnd() + } + } +} diff --git a/ui/handlers.go b/ui/handlers.go new file mode 100644 index 0000000..427b77c --- /dev/null +++ b/ui/handlers.go @@ -0,0 +1,293 @@ +package ui + +import ( + "sort" + "strings" + + "github.com/atotto/clipboard" + "github.com/ayntgl/discordgo" + "github.com/ayntgl/discordo/config" + "github.com/ayntgl/discordo/util" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func onAppInputCapture(app *App, e *tcell.EventKey) *tcell.EventKey { + if hasKeybinding(config.Keybindings.FocusChannelsTreeView, e.Name()) { + app.SetFocus(app.ChannelsTreeView) + return nil + } else if hasKeybinding(config.Keybindings.FocusMessagesView, e.Name()) { + app.SetFocus(app.MessagesTextView) + return nil + } else if hasKeybinding(config.Keybindings.FocusMessageInputField, e.Name()) { + app.SetFocus(app.MessageInputField) + return nil + } + + return e +} + +func onGuildsListSelected(app *App, guildIdx int) { + rootTreeNode := app.ChannelsTreeView.GetRoot() + rootTreeNode.ClearChildren() + app.MessagesTextView. + Highlight(). + Clear(). + SetTitle("") + app.MessageInputField.SetText("") + + if guildIdx == 0 { // Direct Messages + cs := app.Session.State.PrivateChannels + sort.Slice(cs, func(i, j int) bool { + return cs[i].LastMessageID > cs[j].LastMessageID + }) + + for _, c := range cs { + channelTreeNode := tview.NewTreeNode(channelToString(c)). + SetReference(c.ID) + rootTreeNode.AddChild(channelTreeNode) + } + } else { // Guild + cs := app.Session.State.Guilds[guildIdx-1].Channels + sort.Slice(cs, func(i, j int) bool { + return cs[i].Position < cs[j].Position + }) + + for _, c := range cs { + if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) && (c.ParentID == "") { + channelTreeNode := tview.NewTreeNode(channelToString(c)). + SetReference(c.ID) + rootTreeNode.AddChild(channelTreeNode) + } + } + + CATEGORY: + for _, c := range cs { + if c.Type == discordgo.ChannelTypeGuildCategory { + for _, nestedChannel := range cs { + if nestedChannel.ParentID == c.ID { + channelTreeNode := tview.NewTreeNode(c.Name). + SetReference(c.ID) + rootTreeNode.AddChild(channelTreeNode) + continue CATEGORY + } + } + + channelTreeNode := tview.NewTreeNode(c.Name). + SetReference(c.ID) + rootTreeNode.AddChild(channelTreeNode) + } + } + + for _, c := range cs { + if (c.Type == discordgo.ChannelTypeGuildText || c.Type == discordgo.ChannelTypeGuildNews) && (c.ParentID != "") { + var parentTreeNode *tview.TreeNode + rootTreeNode.Walk(func(node, _ *tview.TreeNode) bool { + if node.GetReference() == c.ParentID { + parentTreeNode = node + return false + } + + return true + }) + + if parentTreeNode != nil { + channelTreeNode := tview.NewTreeNode(channelToString(c)). + SetReference(c.ID) + parentTreeNode.AddChild(channelTreeNode) + } + } + } + } + + app.ChannelsTreeView.SetCurrentNode(rootTreeNode) + app.SetFocus(app.ChannelsTreeView) +} + +func onChannelsTreeViewSelected(app *App, n *tview.TreeNode) { + c, err := app.Session.State.Channel(n.GetReference().(string)) + if err != nil { + return + } + + if c.Type == discordgo.ChannelTypeGuildCategory { + n.SetExpanded(!n.IsExpanded()) + return + } + + app.SelectedChannel = c + + app.MessagesTextView.SetTitle(channelToString(c)) + app.SetFocus(app.MessageInputField) + + go func() { + ms, err := app.Session.ChannelMessages(c.ID, config.General.FetchMessagesLimit, "", "", "") + if err != nil { + return + } + + for i := len(ms) - 1; i >= 0; i-- { + app.SelectedChannel.Messages = ms + app.MessagesTextView.Write(buildMessage(app, ms[i])) + } + + app.MessagesTextView.ScrollToEnd() + }() +} + +func onMessagesTextViewInputCapture(app *App, e *tcell.EventKey) *tcell.EventKey { + if app.SelectedChannel == nil { + return nil + } + + ms := app.SelectedChannel.Messages + if len(ms) == 0 { + return nil + } + + if hasKeybinding(config.Keybindings.SelectPreviousMessage, e.Name()) { + if len(app.MessagesTextView.GetHighlights()) == 0 { + app.SelectedMessage = len(ms) - 1 + } else { + app.SelectedMessage-- + if app.SelectedMessage < 0 { + app.SelectedMessage = 0 + } + } + + app.MessagesTextView. + Highlight(ms[app.SelectedMessage].ID). + ScrollToHighlight() + return nil + } else if hasKeybinding(config.Keybindings.SelectNextMessage, e.Name()) { + if len(app.MessagesTextView.GetHighlights()) == 0 { + app.SelectedMessage = len(ms) - 1 + } else { + app.SelectedMessage++ + if app.SelectedMessage >= len(ms) { + app.SelectedMessage = len(ms) - 1 + } + } + + app.MessagesTextView. + Highlight(ms[app.SelectedMessage].ID). + ScrollToHighlight() + return nil + } else if hasKeybinding(config.Keybindings.SelectFirstMessage, e.Name()) { + app.SelectedMessage = 0 + app.MessagesTextView. + Highlight(ms[app.SelectedMessage].ID). + ScrollToHighlight() + return nil + } else if hasKeybinding(config.Keybindings.SelectLastMessage, e.Name()) { + app.SelectedMessage = len(ms) - 1 + app.MessagesTextView. + Highlight(ms[app.SelectedMessage].ID). + ScrollToHighlight() + return nil + } else if hasKeybinding(config.Keybindings.SelectMessageReference, e.Name()) { + hs := app.MessagesTextView.GetHighlights() + if len(hs) == 0 { + return nil + } + + _, m := util.FindMessageByID(app.SelectedChannel.Messages, hs[0]) + if m.ReferencedMessage != nil { + app.SelectedMessage, _ = util.FindMessageByID(app.SelectedChannel.Messages, m.ReferencedMessage.ID) + app.MessagesTextView. + Highlight(m.ReferencedMessage.ID). + ScrollToHighlight() + } + + return nil + } else if hasKeybinding(config.Keybindings.ReplySelectedMessage, e.Name()) { + hs := app.MessagesTextView.GetHighlights() + if len(hs) == 0 { + return nil + } + + _, m := util.FindMessageByID(app.SelectedChannel.Messages, hs[0]) + app.MessageInputField.SetTitle("Replying to " + m.Author.String()) + app.SetFocus(app.MessageInputField) + return nil + } else if hasKeybinding(config.Keybindings.MentionReplySelectedMessage, e.Name()) { + hs := app.MessagesTextView.GetHighlights() + if len(hs) == 0 { + return nil + } + + _, m := util.FindMessageByID(app.SelectedChannel.Messages, hs[0]) + app.MessageInputField.SetTitle("[@] Replying to " + m.Author.String()) + app.SetFocus(app.MessageInputField) + return nil + } else if hasKeybinding(config.Keybindings.CopySelectedMessage, e.Name()) { + hs := app.MessagesTextView.GetHighlights() + if len(hs) == 0 { + return nil + } + + _, m := util.FindMessageByID(app.SelectedChannel.Messages, hs[0]) + err := clipboard.WriteAll(m.Content) + if err != nil { + return nil + } + + return nil + } + + return e +} + +func onMessageInputFieldInputCapture(app *App, e *tcell.EventKey) *tcell.EventKey { + switch e.Key() { + case tcell.KeyEnter: + if app.SelectedChannel == nil { + return nil + } + + t := strings.TrimSpace(app.MessageInputField.GetText()) + if t == "" { + return nil + } + + if len(app.MessagesTextView.GetHighlights()) != 0 { + _, m := util.FindMessageByID(app.SelectedChannel.Messages, app.MessagesTextView.GetHighlights()[0]) + d := &discordgo.MessageSend{ + Content: t, + Reference: m.Reference(), + AllowedMentions: &discordgo.MessageAllowedMentions{RepliedUser: false}, + } + if strings.HasPrefix(app.MessageInputField.GetTitle(), "[@]") { + d.AllowedMentions.RepliedUser = true + } else { + d.AllowedMentions.RepliedUser = false + } + + go app.Session.ChannelMessageSendComplex(m.ChannelID, d) + + app.SelectedMessage = -1 + app.MessagesTextView.Highlight() + + app.MessageInputField.SetTitle("") + } else { + go app.Session.ChannelMessageSend(app.SelectedChannel.ID, t) + } + + app.MessageInputField.SetText("") + return nil + case tcell.KeyCtrlV: + text, _ := clipboard.ReadAll() + text = app.MessageInputField.GetText() + text + app.MessageInputField.SetText(text) + return nil + case tcell.KeyEscape: + app.MessageInputField.SetText("") + app.MessageInputField.SetTitle("") + + app.SelectedMessage = -1 + app.MessagesTextView.Highlight() + return nil + } + + return e +} diff --git a/discord.go b/ui/util.go similarity index 60% rename from discord.go rename to ui/util.go index d915cce..78453ce 100644 --- a/discord.go +++ b/ui/util.go @@ -1,16 +1,11 @@ -package main +package ui import ( - "encoding/json" "fmt" "regexp" - "sort" "strings" "github.com/ayntgl/discordgo" - "github.com/ayntgl/discordo/config" - "github.com/gen2brain/beeep" - "github.com/rivo/tview" ) var ( @@ -20,121 +15,36 @@ var ( strikeThroughRegex = regexp.MustCompile(`(?m)~~(.*?)~~`) ) -func onSessionReady(_ *discordgo.Session, r *discordgo.Ready) { - dmNode := tview.NewTreeNode("Direct Messages"). - Collapse() - n := channelsTreeView.GetRoot() - n.AddChild(dmNode) - - 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 - }) - - for _, g := range r.Guilds { - gn := tview.NewTreeNode(g.Name). - SetReference(g.ID). - Collapse() - n.AddChild(gn) - } - - channelsTreeView.SetCurrentNode(n) -} - -func onSessionMessageCreate(_ *discordgo.Session, m *discordgo.MessageCreate) { - if selectedChannel == nil || selectedChannel.ID != m.ChannelID { - if config.General.Notifications { - for _, u := range m.Mentions { - if u.ID == app.Session.State.User.ID { - g, err := app.Session.State.Guild(m.GuildID) - if err != nil { - return - } - - c, err := app.Session.State.Channel(m.ChannelID) - if err != nil { - return - } - - go beeep.Alert(fmt.Sprintf("%s (#%s)", g.Name, c.Name), m.ContentWithMentionsReplaced(), "") - } - } - } +func channelToString(c *discordgo.Channel) string { + var repr string + if c.Name != "" { + repr = "#" + c.Name + } else if len(c.Recipients) == 1 { + rp := c.Recipients[0] + repr = rp.Username + "#" + rp.Discriminator } else { - selectedChannel.Messages = append(selectedChannel.Messages, m.Message) - messagesTextView.Write(buildMessage(m.Message)) + rps := make([]string, len(c.Recipients)) + for i, r := range c.Recipients { + rps[i] = r.Username + "#" + r.Discriminator + } - // Scroll to the end of the text if a message is not selected. - if len(messagesTextView.GetHighlights()) == 0 { - messagesTextView.ScrollToEnd() + repr = strings.Join(rps, ", ") + } + + return repr +} + +func hasKeybinding(ks []string, k string) bool { + for _, repr := range ks { + if repr == k { + return true } } + + return false } -type loginResponse struct { - MFA bool `json:"mfa"` - SMS bool `json:"sms"` - Ticket string `json:"ticket"` - Token string `json:"token"` -} - -func login(email, password string) (*loginResponse, error) { - data := struct { - Email string `json:"email"` - Password string `json:"password"` - }{email, password} - resp, err := app.Session.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(code, ticket string) (*loginResponse, error) { - data := struct { - Code string `json:"code"` - Ticket string `json:"ticket"` - }{code, ticket} - e := discordgo.EndpointAuth + "mfa/totp" - resp, err := app.Session.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 -} - -func buildMessage(m *discordgo.Message) []byte { +func buildMessage(app *App, m *discordgo.Message) []byte { var b strings.Builder switch m.Type { @@ -146,11 +56,11 @@ func buildMessage(m *discordgo.Message) []byte { b.WriteString(m.ID) b.WriteString("\"]") // Build the message associated with crosspost, channel follow add, pin, or a reply. - buildReferencedMessage(&b, m.ReferencedMessage) + buildReferencedMessage(&b, m.ReferencedMessage, app.Session.State.User.ID) // Build the author of this message. - buildAuthor(&b, m.Author) + buildAuthor(&b, m.Author, app.Session.State.User.ID) // Build the contents of the message. - buildContent(&b, m) + buildContent(&b, m, app.Session.State.User.ID) if m.EditedTimestamp != "" { b.WriteString(" [::d](edited)[::-]") @@ -194,14 +104,14 @@ func buildMessage(m *discordgo.Message) []byte { return nil } -func buildReferencedMessage(b *strings.Builder, rm *discordgo.Message) { +func buildReferencedMessage(b *strings.Builder, rm *discordgo.Message, clientID string) { if rm != nil { b.WriteString(" ╭ ") b.WriteString("[::d]") - buildAuthor(b, rm.Author) + buildAuthor(b, rm.Author, clientID) if rm.Content != "" { - rm.Content = buildMentions(rm.Content, rm.Mentions) + rm.Content = buildMentions(rm.Content, rm.Mentions, clientID) b.WriteString(parseMarkdown(rm.Content)) } @@ -210,9 +120,9 @@ func buildReferencedMessage(b *strings.Builder, rm *discordgo.Message) { } } -func buildContent(b *strings.Builder, m *discordgo.Message) { +func buildContent(b *strings.Builder, m *discordgo.Message, clientID string) { if m.Content != "" { - m.Content = buildMentions(m.Content, m.Mentions) + m.Content = buildMentions(m.Content, m.Mentions, clientID) b.WriteString(parseMarkdown(m.Content)) } } @@ -292,10 +202,10 @@ func buildAttachments(b *strings.Builder, as []*discordgo.MessageAttachment) { } } -func buildMentions(content string, mentions []*discordgo.User) string { +func buildMentions(content string, mentions []*discordgo.User, clientID string) string { for _, mUser := range mentions { var color string - if mUser.ID == app.Session.State.User.ID { + if mUser.ID == clientID { color = "[:#5865F2]" } else { color = "[#EB459E]" @@ -314,8 +224,8 @@ func buildMentions(content string, mentions []*discordgo.User) string { return content } -func buildAuthor(b *strings.Builder, u *discordgo.User) { - if u.ID == app.Session.State.User.ID { +func buildAuthor(b *strings.Builder, u *discordgo.User, clientID string) { + if u.ID == clientID { b.WriteString("[#57F287]") } else { b.WriteString("[#ED4245]") diff --git a/ui/views.go b/ui/views.go index a0bf502..d91d3c9 100644 --- a/ui/views.go +++ b/ui/views.go @@ -1,6 +1,72 @@ package ui -import "github.com/rivo/tview" +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func DrawMainFlex(app *App) { + app.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey { + return onAppInputCapture(app, e) + }) + + app.GuildsList. + ShowSecondaryText(false). + AddItem("Direct Messages", "", 0, nil). + SetSelectedFunc(func(guildIdx int, _ string, _ string, _ rune) { + onGuildsListSelected(app, guildIdx) + }). + SetTitle("Guilds"). + SetTitleAlign(tview.AlignLeft). + SetBorder(true). + SetBorderPadding(0, 0, 1, 1) + + app.ChannelsTreeView. + SetTopLevel(1). + SetRoot(tview.NewTreeNode("")). + SetSelectedFunc(func(n *tview.TreeNode) { + onChannelsTreeViewSelected(app, n) + }). + SetTitle("Channels"). + SetTitleAlign(tview.AlignLeft). + SetBorder(true). + SetBorderPadding(0, 0, 1, 1) + + app.MessagesTextView. + SetRegions(true). + SetDynamicColors(true). + SetWordWrap(true). + SetChangedFunc(func() { app.Draw() }). + SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey { + return onMessagesTextViewInputCapture(app, e) + }). + SetTitleAlign(tview.AlignLeft). + SetBorder(true). + SetBorderPadding(0, 0, 1, 1) + + app.MessageInputField. + SetPlaceholder("Message..."). + SetFieldBackgroundColor(tview.Styles.PrimitiveBackgroundColor). + SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey { + return onMessageInputFieldInputCapture(app, e) + }). + SetTitleAlign(tview.AlignLeft). + SetBorder(true). + SetBorderPadding(0, 0, 1, 0) + + leftFlex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(app.GuildsList, 10, 1, false). + AddItem(app.ChannelsTreeView, 0, 1, false) + rightFlex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(app.MessagesTextView, 0, 1, false). + AddItem(app.MessageInputField, 3, 1, false) + + app.MainFlex. + AddItem(leftFlex, 0, 1, false). + AddItem(rightFlex, 0, 4, false) +} func NewLoginForm(onLoginFormLoginButtonSelected func(), mfa bool) *tview.Form { f := tview.NewForm() diff --git a/util/discord.go b/util/discord.go index d9b60e0..31e72fc 100644 --- a/util/discord.go +++ b/util/discord.go @@ -1,8 +1,9 @@ package util -import "github.com/ayntgl/discordgo" +import ( + "github.com/ayntgl/discordgo" +) -// FindMessageByID returns the index and the `*Message` struct of the current message if the given message ID *mID* is equal to the current message ID. If the given message ID *mID* is not found in the given slice *ms*, `-1` and `nil` are returned instead. func FindMessageByID(ms []*discordgo.Message, mID string) (int, *discordgo.Message) { for i, m := range ms { if m.ID == mID { @@ -13,7 +14,6 @@ func FindMessageByID(ms []*discordgo.Message, mID string) (int, *discordgo.Messa return -1, nil } -// ChannelIsUnread returns `true` if the given channel is marked as read by the client user, otherwise `false`. func ChannelIsUnread(s *discordgo.State, c *discordgo.Channel) bool { if c.LastMessageID == "" { return false @@ -28,7 +28,6 @@ func ChannelIsUnread(s *discordgo.State, c *discordgo.Channel) bool { return false } -// HasPermission returns a boolean that indicates whether the client user has the given permission *p* in the given channel ID *cID*. func HasPermission(s *discordgo.State, cID string, p int64) bool { perm, err := s.UserChannelPermissions(s.User.ID, cID) if err != nil { diff --git a/util/ui.go b/util/ui.go index 6f97e6d..07f1cdd 100644 --- a/util/ui.go +++ b/util/ui.go @@ -60,14 +60,3 @@ func CreateChannelNode(s *discordgo.State, c *discordgo.Channel) *tview.TreeNode return cn } - -// HasKeybinding returns a boolean that indicates whether the given keybinding string representation *k* is in the slice *ks*. -func HasKeybinding(ks []string, k string) bool { - for _, repr := range ks { - if repr == k { - return true - } - } - - return false -}